diff --git a/.env.example b/.env.example
index c4bc767..ec934df 100644
--- a/.env.example
+++ b/.env.example
@@ -75,6 +75,7 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
VITE_APP_NAME="${APP_NAME}"
+VITE_ENABLE_TENANT_SWITCHER=false
REVENUECAT_WEBHOOK_SECRET=
REVENUECAT_PRODUCT_MAPPINGS=
REVENUECAT_APP_USER_PREFIX=tenant
diff --git a/AGENTS.md b/AGENTS.md
index a829be8..cfa9446 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,3 +82,571 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- docs/prp/06-tenant-admin-pwa.md: Detailed PWA specifications.
- docs/prp/07-guest-pwa.md: Guest PWA requirements and features.
- docs/prp/08-billing.md: Payment system architecture.
+
+===
+
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+
+## Foundational Context
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.3.6
+- filament/filament (FILAMENT) - v4
+- inertiajs/inertia-laravel (INERTIA) - v2
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/socialite (SOCIALITE) - v5
+- laravel/wayfinder (WAYFINDER) - v0
+- livewire/livewire (LIVEWIRE) - v3
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+- @inertiajs/react (INERTIA) - v2
+- react (REACT) - v19
+- tailwindcss (TAILWINDCSS) - v4
+- @laravel/vite-plugin-wayfinder (WAYFINDER) - v0
+- eslint (ESLINT) - v9
+- prettier (PRETTIER) - v3
+
+
+## Conventions
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+- Stick to existing directory structure - don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
+
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+
+=== filament/core rules ===
+
+## Filament
+- Filament is used by this application, check how and where to follow existing application conventions.
+- Filament is a Server-Driven UI (SDUI) framework for Laravel. It allows developers to define user interfaces in PHP using structured configuration objects. It is built on top of Livewire, Alpine.js, and Tailwind CSS.
+- You can use the `search-docs` tool to get information from the official Filament documentation when needed. This is very useful for Artisan command arguments, specific code examples, testing functionality, relationship management, and ensuring you're following idiomatic practices.
+- Utilize static `make()` methods for consistent component initialization.
+
+### Artisan
+- You must use the Filament specific Artisan commands to create new files or components for Filament. You can find these with the `list-artisan-commands` tool, or with `php artisan` and the `--help` option.
+- Inspect the required options, always pass `--no-interaction`, and valid arguments for other options when applicable.
+
+### Filament's Core Features
+- Actions: Handle doing something within the application, often with a button or link. Actions encapsulate the UI, the interactive modal window, and the logic that should be executed when the modal window is submitted. They can be used anywhere in the UI and are commonly used to perform one-time actions like deleting a record, sending an email, or updating data in the database based on modal form input.
+- Forms: Dynamic forms rendered within other features, such as resources, action modals, table filters, and more.
+- Infolists: Read-only lists of data.
+- Notifications: Flash notifications displayed to users within the application.
+- Panels: The top-level container in Filament that can include all other features like pages, resources, forms, tables, notifications, actions, infolists, and widgets.
+- Resources: Static classes that are used to build CRUD interfaces for Eloquent models. Typically live in `app/Filament/Resources`.
+- Schemas: Represent components that define the structure and behavior of the UI, such as forms, tables, or lists.
+- Tables: Interactive tables with filtering, sorting, pagination, and more.
+- Widgets: Small component included within dashboards, often used for displaying data in charts, tables, or as a stat.
+
+### Relationships
+- Determine if you can use the `relationship()` method on form components when you need `options` for a select, checkbox, repeater, or when building a `Fieldset`:
+
+
+Forms\Components\Select::make('user_id')
+ ->label('Author')
+ ->relationship('author')
+ ->required(),
+
+
+
+## Testing
+- It's important to test Filament functionality for user satisfaction.
+- Ensure that you are authenticated to access the application within the test.
+- Filament uses Livewire, so start assertions with `livewire()` or `Livewire::test()`.
+
+### Example Tests
+
+
+ livewire(ListUsers::class)
+ ->assertCanSeeTableRecords($users)
+ ->searchTable($users->first()->name)
+ ->assertCanSeeTableRecords($users->take(1))
+ ->assertCanNotSeeTableRecords($users->skip(1))
+ ->searchTable($users->last()->email)
+ ->assertCanSeeTableRecords($users->take(-1))
+ ->assertCanNotSeeTableRecords($users->take($users->count() - 1));
+
+
+
+ livewire(CreateUser::class)
+ ->fillForm([
+ 'name' => 'Howdy',
+ 'email' => 'howdy@example.com',
+ ])
+ ->call('create')
+ ->assertNotified()
+ ->assertRedirect();
+
+ assertDatabaseHas(User::class, [
+ 'name' => 'Howdy',
+ 'email' => 'howdy@example.com',
+ ]);
+
+
+
+ use Filament\Facades\Filament;
+
+ Filament::setCurrentPanel('app');
+
+
+
+ livewire(EditInvoice::class, [
+ 'invoice' => $invoice,
+ ])->callAction('send');
+
+ expect($invoice->refresh())->isSent()->toBeTrue();
+
+
+
+=== filament/v4 rules ===
+
+## Filament 4
+
+### Important Version 4 Changes
+- File visibility is now `private` by default.
+- The `deferFilters` method from Filament v3 is now the default behavior in Filament v4, so users must click a button before the filters are applied to the table. To disable this behavior, you can use the `deferFilters(false)` method.
+- The `Grid`, `Section`, and `Fieldset` layout components no longer span all columns by default.
+- The `all` pagination page method is not available for tables by default.
+- All action classes extend `Filament\Actions\Action`. No action classes exist in `Filament\Tables\Actions`.
+- The `Form` & `Infolist` layout components have been moved to `Filament\Schemas\Components`, for example `Grid`, `Section`, `Fieldset`, `Tabs`, `Wizard`, etc.
+- A new `Repeater` component for Forms has been added.
+- Icons now use the `Filament\Support\Icons\Heroicon` Enum by default. Other options are available and documented.
+
+### Organize Component Classes Structure
+- Schema components: `Schemas/Components/`
+- Table columns: `Tables/Columns/`
+- Table filters: `Tables/Filters/`
+- Actions: `Actions/`
+
+
+=== inertia-laravel/core rules ===
+
+## Inertia Core
+
+- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (vite.config.js).
+- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
+- Use `search-docs` for accurate guidance on all things Inertia.
+
+
+// routes/web.php example
+Route::get('/users', function () {
+ return Inertia::render('Users/Index', [
+ 'users' => User::all()
+ ]);
+});
+
+
+
+=== inertia-laravel/v2 rules ===
+
+## Inertia v2
+
+- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
+
+### Inertia v2 New Features
+- Polling
+- Prefetching
+- Deferred props
+- Infinite scrolling using merging props and `WhenVisible`
+- Lazy loading data on scroll
+
+### Deferred Props & Empty States
+- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
+
+### Inertia Form General Guidance
+- The recommended way to build forms when using Inertia is with the `
diff --git a/app/Console/Commands/BackfillEventInvitations.php b/app/Console/Commands/BackfillEventInvitations.php
new file mode 100644
index 0000000..0c6042f
--- /dev/null
+++ b/app/Console/Commands/BackfillEventInvitations.php
@@ -0,0 +1,68 @@
+option('tenant');
+ $limit = (int) $this->option('limit');
+ $dryRun = (bool) $this->option('dry-run');
+
+ $query = Event::query()
+ ->doesntHave('joinTokens')
+ ->orderBy('id');
+
+ if ($tenantId) {
+ $query->where('tenant_id', $tenantId);
+ }
+
+ $processed = 0;
+
+ $query->chunkById(50, function ($events) use ($joinTokenService, $dryRun, &$processed, $limit) {
+ foreach ($events as $event) {
+ if ($processed >= $limit) {
+ return false;
+ }
+
+ $processed++;
+
+ if ($dryRun) {
+ $this->line("[dry-run] Would create invitation for event #{$event->id} ({$event->slug})");
+
+ continue;
+ }
+
+ $joinTokenService->createToken($event, [
+ 'label' => 'Standard-Link',
+ 'metadata' => [
+ 'auto_generated' => true,
+ 'backfilled_at' => now()->toIso8601String(),
+ ],
+ ]);
+
+ $this->line("Created invitation for event #{$event->id} ({$event->slug})");
+ }
+
+ return true;
+ });
+
+ if ($processed === 0) {
+ $this->info('No events required backfilling.');
+ } else {
+ $suffix = $dryRun ? ' (dry-run)' : '';
+ $this->info("Processed {$processed} event(s){$suffix}.");
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php
index c8a08c2..6bc5c17 100644
--- a/app/Http/Controllers/Api/PackageController.php
+++ b/app/Http/Controllers/Api/PackageController.php
@@ -7,18 +7,16 @@ use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
-use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
-use Stripe\Stripe;
-use Stripe\PaymentIntent;
-use PayPal\PayPalClient;
-use PayPal\Environment\SandboxEnvironment;
-use PayPal\Environment\LiveEnvironment;
-use PayPal\Checkout\Orders\OrdersCreateRequest;
use PayPal\Checkout\Orders\OrdersCaptureRequest;
+use PayPal\Checkout\Orders\OrdersCreateRequest;
+use PayPal\Environment\LiveEnvironment;
+use PayPal\Environment\SandboxEnvironment;
+use PayPal\PayPalClient;
class PackageController extends Controller
{
@@ -30,7 +28,16 @@ class PackageController extends Controller
->get();
$packages->each(function ($package) {
- $package->features = json_decode($package->features ?? '[]', true);
+ if (is_string($package->features)) {
+ $decoded = json_decode($package->features, true);
+ $package->features = is_array($decoded) ? $decoded : [];
+
+ return;
+ }
+
+ if (! is_array($package->features)) {
+ $package->features = [];
+ }
});
return response()->json([
@@ -51,7 +58,7 @@ class PackageController extends Controller
$package = Package::findOrFail($request->package_id);
$tenant = $request->attributes->get('tenant');
- if (!$tenant) {
+ if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
@@ -73,7 +80,7 @@ class PackageController extends Controller
$package = Package::findOrFail($request->package_id);
$tenant = $request->attributes->get('tenant');
- if (!$tenant) {
+ if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
@@ -105,7 +112,7 @@ class PackageController extends Controller
$package = Package::findOrFail($request->package_id);
$tenant = $request->attributes->get('tenant');
- if (!$tenant) {
+ if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
@@ -146,7 +153,7 @@ class PackageController extends Controller
$package = Package::findOrFail($request->package_id);
$tenant = $request->attributes->get('tenant');
- if (!$tenant) {
+ if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
@@ -154,7 +161,7 @@ class PackageController extends Controller
throw ValidationException::withMessages(['package' => 'Not a free package.']);
}
- DB::transaction(function () use ($request, $package, $tenant) {
+ DB::transaction(function () use ($package, $tenant) {
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
@@ -188,7 +195,7 @@ class PackageController extends Controller
$package = Package::findOrFail($request->package_id);
$tenant = $request->attributes->get('tenant');
- if (!$tenant) {
+ if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
@@ -202,25 +209,25 @@ class PackageController extends Controller
$client = PayPalClient::client($environment);
- $request = new OrdersCreateRequest();
+ $request = new OrdersCreateRequest;
$request->prefer('return=representation');
$request->body = [
- "intent" => "CAPTURE",
- "purchase_units" => [[
- "amount" => [
- "currency_code" => "EUR",
- "value" => number_format($package->price, 2, '.', ''),
+ 'intent' => 'CAPTURE',
+ 'purchase_units' => [[
+ 'amount' => [
+ 'currency_code' => 'EUR',
+ 'value' => number_format($package->price, 2, '.', ''),
],
- "description" => 'Fotospiel Package: ' . $package->name,
- "custom_id" => json_encode([
+ 'description' => 'Fotospiel Package: '.$package->name,
+ 'custom_id' => json_encode([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'user_id' => $tenant->user_id ?? null,
]),
]],
- "application_context" => [
- "shipping_preference" => "NO_SHIPPING",
- "user_action" => "PAY_NOW",
+ 'application_context' => [
+ 'shipping_preference' => 'NO_SHIPPING',
+ 'user_action' => 'PAY_NOW',
],
];
@@ -232,7 +239,7 @@ class PackageController extends Controller
'orderID' => $order->id,
]);
} catch (\Exception $e) {
- Log::error('PayPal order creation error: ' . $e->getMessage());
+ Log::error('PayPal order creation error: '.$e->getMessage());
throw ValidationException::withMessages(['payment' => 'PayPal-Bestellung fehlgeschlagen.']);
}
}
@@ -263,11 +270,11 @@ class PackageController extends Controller
$capture = $response->result;
if ($capture->status !== 'COMPLETED') {
- throw new \Exception('PayPal capture not completed: ' . $capture->status);
+ throw new \Exception('PayPal capture not completed: '.$capture->status);
}
$customId = $capture->purchaseUnits[0]->customId ?? null;
- if (!$customId) {
+ if (! $customId) {
throw new \Exception('No metadata in PayPal order');
}
@@ -275,7 +282,7 @@ class PackageController extends Controller
$tenant = Tenant::find($metadata['tenant_id']);
$package = Package::find($metadata['package_id']);
- if (!$tenant || !$package) {
+ if (! $tenant || ! $package) {
throw new \Exception('Tenant or package not found');
}
@@ -325,8 +332,9 @@ class PackageController extends Controller
return response()->json(['success' => true, 'message' => 'Payment successful']);
} catch (\Exception $e) {
- Log::error('PayPal capture error: ' . $e->getMessage(), ['order_id' => $orderId]);
- return response()->json(['success' => false, 'message' => 'Capture failed: ' . $e->getMessage()], 422);
+ Log::error('PayPal capture error: '.$e->getMessage(), ['order_id' => $orderId]);
+
+ return response()->json(['success' => false, 'message' => 'Capture failed: '.$e->getMessage()], 422);
}
}
@@ -380,14 +388,16 @@ class PackageController extends Controller
$type = $request->type;
if ($type === 'reseller_subscription') {
- $response = (new StripeController())->createSubscription($request);
+ $response = (new StripeController)->createSubscription($request);
+
return $response;
} else {
- $response = (new StripeController())->createPaymentIntent($request);
+ $response = (new StripeController)->createPaymentIntent($request);
+
return $response;
}
}
// Helper for PayPal client - add this if not exists, or use global
// But since SDK has PayPalClient, assume it's used
-}
\ No newline at end of file
+}
diff --git a/app/Http/Controllers/Api/Tenant/DashboardController.php b/app/Http/Controllers/Api/Tenant/DashboardController.php
new file mode 100644
index 0000000..61f8e4e
--- /dev/null
+++ b/app/Http/Controllers/Api/Tenant/DashboardController.php
@@ -0,0 +1,83 @@
+attributes->get('tenant');
+
+ if (! $tenant instanceof Tenant) {
+ $decoded = $request->attributes->get('decoded_token', []);
+ $tenantId = Arr::get($decoded, 'tenant_id');
+
+ if ($tenantId) {
+ $tenant = Tenant::query()->find($tenantId);
+ }
+ }
+
+ if (! $tenant instanceof Tenant) {
+ return response()->json([
+ 'message' => 'Tenant context missing.',
+ ], 401);
+ }
+
+ $eventsQuery = Event::query()
+ ->where('tenant_id', $tenant->getKey());
+
+ $activeEvents = (clone $eventsQuery)
+ ->where(fn ($query) => $query
+ ->where('is_active', true)
+ ->orWhere('status', 'published'))
+ ->count();
+
+ $upcomingEvents = (clone $eventsQuery)
+ ->whereDate('date', '>=', Carbon::now()->startOfDay())
+ ->count();
+
+ $eventsWithTasks = (clone $eventsQuery)
+ ->whereHas('tasks')
+ ->count();
+
+ $totalEvents = (clone $eventsQuery)->count();
+
+ $taskProgress = $totalEvents > 0
+ ? (int) round(($eventsWithTasks / $totalEvents) * 100)
+ : 0;
+
+ $newPhotos = Photo::query()
+ ->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
+ ->where('created_at', '>=', Carbon::now()->subDays(7))
+ ->count();
+
+ $activePackage = $tenant->tenantPackages()
+ ->with('package')
+ ->where('active', true)
+ ->orderByDesc('expires_at')
+ ->orderByDesc('purchased_at')
+ ->first();
+
+ return response()->json([
+ 'active_events' => $activeEvents,
+ 'new_photos' => $newPhotos,
+ 'task_progress' => $taskProgress,
+ 'credit_balance' => $tenant->event_credits_balance ?? null,
+ 'upcoming_events' => $upcomingEvents,
+ 'active_package' => $activePackage ? [
+ 'name' => $activePackage->package?->getNameForLocale(app()->getLocale()) ?? $activePackage->package?->name ?? '',
+ 'expires_at' => optional($activePackage->expires_at)->toIso8601String(),
+ 'remaining_events' => $activePackage->remaining_events ?? null,
+ ] : null,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php
index d3f62bb..1ff27b0 100644
--- a/app/Http/Controllers/Api/Tenant/EventController.php
+++ b/app/Http/Controllers/Api/Tenant/EventController.php
@@ -4,11 +4,12 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventStoreRequest;
-use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Http\Resources\Tenant\EventResource;
use App\Models\Event;
-use App\Models\Tenant;
+use App\Models\EventPackage;
+use App\Models\Package;
use App\Models\Photo;
+use App\Models\Tenant;
use App\Services\EventJoinTokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -21,9 +22,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
class EventController extends Controller
{
- public function __construct(private readonly EventJoinTokenService $joinTokenService)
- {
- }
+ public function __construct(private readonly EventJoinTokenService $joinTokenService) {}
public function index(Request $request): AnonymousResourceCollection
{
@@ -36,7 +35,12 @@ class EventController extends Controller
}
$query = Event::where('tenant_id', $tenantId)
- ->with(['eventType', 'photos'])
+ ->with([
+ 'eventType',
+ 'photos',
+ 'eventPackages.package',
+ 'eventPackage.package',
+ ])
->orderBy('created_at', 'desc');
if ($request->has('status')) {
@@ -65,9 +69,31 @@ class EventController extends Controller
$validated = $request->validated();
$tenantId = $tenant->id;
- $packageId = $validated['package_id'] ?? 1; // Default to Free package ID 1
+ $requestedPackageId = $validated['package_id'] ?? null;
unset($validated['package_id']);
+ $tenantPackage = $tenant->tenantPackages()
+ ->with('package')
+ ->where('active', true)
+ ->orderByDesc('purchased_at')
+ ->first();
+
+ $package = null;
+
+ if ($requestedPackageId) {
+ $package = Package::query()->find($requestedPackageId);
+ }
+
+ if (! $package && $tenantPackage) {
+ $package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
+ }
+
+ if (! $package) {
+ throw ValidationException::withMessages([
+ 'package_id' => __('Aktuell ist kein aktives Paket verfügbar. Bitte buche zunächst ein Paket.'),
+ ]);
+ }
+
$eventData = array_merge($validated, [
'tenant_id' => $tenantId,
'status' => $validated['status'] ?? 'draft',
@@ -122,37 +148,30 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed);
- $event = DB::transaction(function () use ($tenant, $eventData, $packageId) {
+ $event = DB::transaction(function () use ($tenant, $eventData, $package) {
$event = Event::create($eventData);
- $package = \App\Models\Package::findOrFail($packageId);
- \App\Models\EventPackage::create([
+ EventPackage::create([
'event_id' => $event->id,
- 'package_id' => $packageId,
- 'price' => $package->price,
+ 'package_id' => $package->id,
+ 'purchased_price' => $package->price,
'purchased_at' => now(),
+ 'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
]);
- \App\Models\PackagePurchase::create([
- 'tenant_id' => $tenant->id,
- 'event_id' => $event->id,
- 'package_id' => $packageId,
- 'provider_id' => 'free',
- 'price' => $package->price,
- 'type' => 'endcustomer_event',
- 'metadata' => json_encode(['note' => 'Free package assigned on event creation']),
- ]);
+ if ($package->isReseller()) {
+ $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)) {
- throw new HttpException(402, 'Insufficient credits or package allowance.');
+ if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
+ throw new HttpException(402, 'Insufficient credits or package allowance.');
+ }
}
return $event;
});
$tenant->refresh();
- $event->load(['eventType', 'tenant', 'eventPackage.package']);
+ $event->load(['eventType', 'tenant', 'eventPackages.package']);
return response()->json([
'message' => 'Event created successfully',
@@ -175,6 +194,10 @@ class EventController extends Controller
'photos' => fn ($query) => $query->with('likes')->latest(),
'tasks',
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'),
+ 'eventPackages' => fn ($query) => $query
+ ->with('package')
+ ->orderByDesc('purchased_at')
+ ->orderByDesc('created_at'),
]);
return response()->json([
@@ -229,7 +252,6 @@ class EventController extends Controller
]);
}
-
public function stats(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
@@ -312,6 +334,7 @@ class EventController extends Controller
'join_token' => new EventJoinTokenResource($joinToken),
]);
}
+
public function bulkUpdateStatus(Request $request): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
@@ -341,7 +364,7 @@ class EventController extends Controller
->where('tenant_id', $tenantId)
->when($excludeId, fn ($query) => $query->where('id', '!=', $excludeId))
->exists()) {
- $slug = $originalSlug . '-' . $counter;
+ $slug = $originalSlug.'-'.$counter;
$counter++;
}
diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php
index 41d85ec..0e3969d 100644
--- a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php
+++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php
@@ -7,17 +7,15 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService;
-use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
class EventJoinTokenController extends Controller
{
- public function __construct(private readonly EventJoinTokenService $joinTokenService)
- {
- }
+ public function __construct(private readonly EventJoinTokenService $joinTokenService) {}
- public function index(Request $request, Event $event): JsonResponse
+ public function index(Request $request, Event $event): AnonymousResourceCollection
{
$this->authorizeEvent($request, $event);
@@ -48,7 +46,7 @@ class EventJoinTokenController extends Controller
->setStatusCode(201);
}
- public function destroy(Request $request, Event $event, EventJoinToken $joinToken): JsonResponse
+ public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
diff --git a/app/Http/Controllers/Api/Tenant/EventTypeController.php b/app/Http/Controllers/Api/Tenant/EventTypeController.php
new file mode 100644
index 0000000..3e34191
--- /dev/null
+++ b/app/Http/Controllers/Api/Tenant/EventTypeController.php
@@ -0,0 +1,20 @@
+orderBy('slug')
+ ->get();
+
+ return EventTypeResource::collection($eventTypes);
+ }
+}
diff --git a/app/Http/Resources/Tenant/EventJoinTokenResource.php b/app/Http/Resources/Tenant/EventJoinTokenResource.php
index 293dc50..9259cd1 100644
--- a/app/Http/Resources/Tenant/EventJoinTokenResource.php
+++ b/app/Http/Resources/Tenant/EventJoinTokenResource.php
@@ -6,6 +6,7 @@ use App\Models\Event;
use App\Support\JoinTokenLayoutRegistry;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Facades\Route;
class EventJoinTokenResource extends JsonResource
{
@@ -18,23 +19,25 @@ class EventJoinTokenResource extends JsonResource
$eventFromRoute = $request->route('event');
$eventContext = $eventFromRoute instanceof Event ? $eventFromRoute : ($this->resource->event ?? null);
- $layouts = $eventContext
- ? JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($eventContext) {
+ $layouts = [];
+ if ($eventContext && Route::has('tenant.events.join-tokens.layouts.download')) {
+ $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($eventContext) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $eventContext,
'joinToken' => $this->resource,
'layout' => $layoutId,
'format' => $format,
]);
- })
- : [];
+ });
+ }
- $layoutsUrl = $eventContext
- ? route('tenant.events.join-tokens.layouts.index', [
+ $layoutsUrl = null;
+ if ($eventContext && Route::has('tenant.events.join-tokens.layouts.index')) {
+ $layoutsUrl = route('tenant.events.join-tokens.layouts.index', [
'event' => $eventContext,
'joinToken' => $this->resource,
- ])
- : null;
+ ]);
+ }
$plainToken = $this->resource->plain_token ?? $this->token;
@@ -50,7 +53,7 @@ class EventJoinTokenResource extends JsonResource
'revoked_at' => optional($this->revoked_at)->toIso8601String(),
'is_active' => $this->isActive(),
'created_at' => optional($this->created_at)->toIso8601String(),
- 'metadata' => $this->metadata ?? new \stdClass(),
+ 'metadata' => $this->metadata ?? new \stdClass,
'layouts_url' => $layoutsUrl,
'layouts' => $layouts,
];
diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php
index 97871b0..1c63736 100644
--- a/app/Http/Resources/Tenant/EventResource.php
+++ b/app/Http/Resources/Tenant/EventResource.php
@@ -4,6 +4,7 @@ namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Http\Resources\MissingValue;
class EventResource extends JsonResource
{
@@ -12,6 +13,21 @@ class EventResource extends JsonResource
$tenantId = $request->attributes->get('tenant_id');
$showSensitive = $this->tenant_id === $tenantId;
$settings = is_array($this->settings) ? $this->settings : [];
+ $eventPackage = null;
+
+ if ($this->relationLoaded('eventPackages')) {
+ $related = $this->getRelation('eventPackages');
+
+ if (! $related instanceof MissingValue) {
+ $eventPackage = $related->first();
+ }
+ } elseif ($this->relationLoaded('eventPackage')) {
+ $related = $this->getRelation('eventPackage');
+
+ if (! $related instanceof MissingValue) {
+ $eventPackage = $related;
+ }
+ }
return [
'id' => $this->id,
@@ -36,6 +52,13 @@ class EventResource extends JsonResource
'is_public' => $this->status === 'published',
'public_share_url' => null,
'qr_code_url' => null,
+ 'package' => $eventPackage ? [
+ 'id' => $eventPackage->package_id,
+ 'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name,
+ 'price' => $eventPackage->purchased_price,
+ 'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
+ 'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
+ ] : null,
];
}
}
diff --git a/app/Http/Resources/Tenant/EventTypeResource.php b/app/Http/Resources/Tenant/EventTypeResource.php
index 2d51cf0..7cdf049 100644
--- a/app/Http/Resources/Tenant/EventTypeResource.php
+++ b/app/Http/Resources/Tenant/EventTypeResource.php
@@ -14,15 +14,19 @@ class EventTypeResource extends JsonResource
*/
public function toArray(Request $request): array
{
+ $nameTranslations = is_array($this->name) ? $this->name : [];
+ $fallbackName = is_string($this->name) ? $this->name : '';
+ $localizedName = $nameTranslations[app()->getLocale()] ?? $nameTranslations['en'] ?? reset($nameTranslations) ?? $fallbackName;
+
return [
'id' => $this->id,
- 'name' => $this->name,
- 'description' => $this->description,
+ 'slug' => $this->slug,
+ 'name' => $localizedName,
+ 'name_translations' => $nameTranslations,
'icon' => $this->icon,
- 'color' => $this->color,
- 'is_active' => $this->is_active,
- 'created_at' => $this->created_at->toISOString(),
- 'updated_at' => $this->updated_at->toISOString(),
+ 'settings' => $this->settings ?? [],
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
];
}
-}
\ No newline at end of file
+}
diff --git a/app/Models/Event.php b/app/Models/Event.php
index 3b5370d..422f2ba 100644
--- a/app/Models/Event.php
+++ b/app/Models/Event.php
@@ -2,21 +2,21 @@
namespace App\Models;
+use App\Services\EventJoinTokenService;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
-use App\Models\EventStorageAssignment;
-use App\Models\EventMediaAsset;
-use App\Models\MediaStorageTarget;
class Event extends Model
{
use HasFactory;
protected $table = 'events';
+
protected $guarded = [];
+
protected $casts = [
'date' => 'datetime',
'is_active' => 'boolean',
@@ -24,6 +24,22 @@ class Event extends Model
'description' => 'array',
];
+ protected static function booted(): void
+ {
+ static::created(function (self $event): void {
+ if ($event->joinTokens()->exists()) {
+ return;
+ }
+
+ app(EventJoinTokenService::class)->createToken($event, [
+ 'label' => 'Standard-Link',
+ 'metadata' => [
+ 'auto_generated' => true,
+ ],
+ ]);
+ });
+ }
+
public function storageAssignments(): HasMany
{
return $this->hasMany(EventStorageAssignment::class);
@@ -98,7 +114,7 @@ class Event extends Model
public function getPackageLimits(): array
{
- if (!$this->hasActivePackage()) {
+ if (! $this->hasActivePackage()) {
return [];
}
@@ -107,7 +123,7 @@ class Event extends Model
public function canUploadPhoto(): bool
{
- if (!$this->hasActivePackage()) {
+ if (! $this->hasActivePackage()) {
return false;
}
diff --git a/app/Models/EventJoinToken.php b/app/Models/EventJoinToken.php
index 518edd9..bee7a19 100644
--- a/app/Models/EventJoinToken.php
+++ b/app/Models/EventJoinToken.php
@@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
-use App\Models\EventJoinTokenEvent;
use Illuminate\Support\Facades\Crypt;
class EventJoinToken extends Model
@@ -15,6 +14,7 @@ class EventJoinToken extends Model
protected $fillable = [
'event_id',
+ 'token',
'token_hash',
'token_encrypted',
'token_preview',
@@ -36,6 +36,7 @@ class EventJoinToken extends Model
];
protected $hidden = [
+ 'token',
'token_encrypted',
'token_hash',
];
diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php
new file mode 100644
index 0000000..59599dc
--- /dev/null
+++ b/app/Providers/HorizonServiceProvider.php
@@ -0,0 +1,36 @@
+email, [
+ //
+ ]);
+ });
+ }
+}
diff --git a/app/Services/EventJoinTokenService.php b/app/Services/EventJoinTokenService.php
index 001d64e..c0c7fdc 100644
--- a/app/Services/EventJoinTokenService.php
+++ b/app/Services/EventJoinTokenService.php
@@ -7,7 +7,6 @@ use App\Models\EventJoinToken;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
class EventJoinTokenService
@@ -16,13 +15,10 @@ class EventJoinTokenService
{
return DB::transaction(function () use ($event, $attributes) {
$tokenValue = $this->generateUniqueToken();
- $tokenHash = $this->hashToken($tokenValue);
$payload = [
'event_id' => $event->id,
- 'token_hash' => $tokenHash,
- 'token_encrypted' => Crypt::encryptString($tokenValue),
- 'token_preview' => $this->previewToken($tokenValue),
+ 'token' => $tokenValue,
'label' => Arr::get($attributes, 'label'),
'usage_limit' => Arr::get($attributes, 'usage_limit'),
'metadata' => Arr::get($attributes, 'metadata', []),
@@ -109,15 +105,4 @@ class EventJoinTokenService
{
return hash('sha256', $token);
}
-
- protected function previewToken(string $token): string
- {
- $length = strlen($token);
-
- if ($length <= 10) {
- return $token;
- }
-
- return substr($token, 0, 6).'…'.substr($token, -4);
- }
}
diff --git a/boost.json b/boost.json
new file mode 100644
index 0000000..76ae75b
--- /dev/null
+++ b/boost.json
@@ -0,0 +1,9 @@
+{
+ "agents": [
+ "codex"
+ ],
+ "editors": [
+ "vscode"
+ ],
+ "guidelines": []
+}
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
index bb7089d..a9a215c 100644
--- a/bootstrap/providers.php
+++ b/bootstrap/providers.php
@@ -3,7 +3,8 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
- Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class,
- App\Providers\Filament\SuperAdminPanelProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
+ App\Providers\Filament\SuperAdminPanelProvider::class,
+ App\Providers\HorizonServiceProvider::class,
+ Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class,
];
diff --git a/composer.json b/composer.json
index a9f72ae..4762097 100644
--- a/composer.json
+++ b/composer.json
@@ -12,6 +12,7 @@
"firebase/php-jwt": "^6.11",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0",
+ "laravel/horizon": "^5.37",
"laravel/sanctum": "^4.2",
"laravel/socialite": "^5.23",
"laravel/tinker": "^2.10.1",
@@ -25,6 +26,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
+ "laravel/boost": "^1.5",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.41",
diff --git a/composer.lock b/composer.lock
index 3e9be3c..e8027bb 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "79b6c96efab0391868c6ce26689c0ce3",
+ "content-hash": "c4ce377acba80c944149cab30605d24c",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -2778,6 +2778,86 @@
},
"time": "2025-10-07T14:30:39+00:00"
},
+ {
+ "name": "laravel/horizon",
+ "version": "v5.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/horizon.git",
+ "reference": "3a970f934e95e396faa0aab53b3a996c6bf47e95"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/horizon/zipball/3a970f934e95e396faa0aab53b3a996c6bf47e95",
+ "reference": "3a970f934e95e396faa0aab53b3a996c6bf47e95",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-pcntl": "*",
+ "ext-posix": "*",
+ "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
+ "illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
+ "illuminate/support": "^9.21|^10.0|^11.0|^12.0",
+ "nesbot/carbon": "^2.17|^3.0",
+ "php": "^8.0",
+ "ramsey/uuid": "^4.0",
+ "symfony/console": "^6.0|^7.0",
+ "symfony/error-handler": "^6.0|^7.0",
+ "symfony/polyfill-php83": "^1.28",
+ "symfony/process": "^6.0|^7.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.0",
+ "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
+ "phpstan/phpstan": "^1.10|^2.0",
+ "phpunit/phpunit": "^9.0|^10.4|^11.5|^12.0",
+ "predis/predis": "^1.1|^2.0|^3.0"
+ },
+ "suggest": {
+ "ext-redis": "Required to use the Redis PHP driver.",
+ "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Horizon": "Laravel\\Horizon\\Horizon"
+ },
+ "providers": [
+ "Laravel\\Horizon\\HorizonServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "6.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Horizon\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Dashboard and code-driven configuration for Laravel queues.",
+ "keywords": [
+ "laravel",
+ "queue"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/horizon/issues",
+ "source": "https://github.com/laravel/horizon/tree/v5.37.0"
+ },
+ "time": "2025-10-21T14:32:49+00:00"
+ },
{
"name": "laravel/prompts",
"version": "v0.3.7",
@@ -9884,6 +9964,145 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
+ {
+ "name": "laravel/boost",
+ "version": "v1.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/boost.git",
+ "reference": "f100f43c1191a0b229a81cfb3d0cbcdf3053c381"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/f100f43c1191a0b229a81cfb3d0cbcdf3053c381",
+ "reference": "f100f43c1191a0b229a81cfb3d0cbcdf3053c381",
+ "shasum": ""
+ },
+ "require": {
+ "guzzlehttp/guzzle": "^7.10",
+ "illuminate/console": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/support": "^10.49.0|^11.45.3|^12.28.1",
+ "laravel/mcp": "^0.2.0|^0.3.0",
+ "laravel/prompts": "0.1.25|^0.3.6",
+ "laravel/roster": "^0.2.9",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "laravel/pint": "1.20",
+ "mockery/mockery": "^1.6.12",
+ "orchestra/testbench": "^8.36.0|^9.15.0|^10.6",
+ "pestphp/pest": "^2.36.0|^3.8.4",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.1"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Boost\\BoostServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Boost\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
+ "homepage": "https://github.com/laravel/boost",
+ "keywords": [
+ "ai",
+ "dev",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/boost/issues",
+ "source": "https://github.com/laravel/boost"
+ },
+ "time": "2025-10-25T02:38:57+00:00"
+ },
+ {
+ "name": "laravel/mcp",
+ "version": "v0.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "4e1389eedb4741a624e26cc3660b31bae04c4342"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/4e1389eedb4741a624e26cc3660b31bae04c4342",
+ "reference": "4e1389eedb4741a624e26cc3660b31bae04c4342",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "illuminate/console": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/container": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/http": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/json-schema": "^12.28.1",
+ "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/support": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/validation": "^10.49.0|^11.45.3|^12.28.1",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "laravel/pint": "1.20.0",
+ "orchestra/testbench": "^8.36.0|^9.15.0|^10.6.0",
+ "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.1.7"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
+ },
+ "providers": [
+ "Laravel\\Mcp\\Server\\McpServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Mcp\\": "src/",
+ "Laravel\\Mcp\\Server\\": "src/Server/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Rapidly build MCP servers for your Laravel applications.",
+ "homepage": "https://github.com/laravel/mcp",
+ "keywords": [
+ "laravel",
+ "mcp"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/mcp/issues",
+ "source": "https://github.com/laravel/mcp"
+ },
+ "time": "2025-10-07T14:28:56+00:00"
+ },
{
"name": "laravel/pail",
"version": "v1.2.3",
@@ -10029,6 +10248,67 @@
},
"time": "2025-09-19T02:57:12+00:00"
},
+ {
+ "name": "laravel/roster",
+ "version": "v0.2.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/roster.git",
+ "reference": "82bbd0e2de614906811aebdf16b4305956816fa6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6",
+ "reference": "82bbd0e2de614906811aebdf16b4305956816fa6",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/console": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/routing": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "php": "^8.1|^8.2",
+ "symfony/yaml": "^6.4|^7.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^8.22.0|^9.0|^10.0",
+ "pestphp/pest": "^2.0|^3.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Roster\\RosterServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Roster\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Detect packages & approaches in use within a Laravel project",
+ "homepage": "https://github.com/laravel/roster",
+ "keywords": [
+ "dev",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/roster/issues",
+ "source": "https://github.com/laravel/roster"
+ },
+ "time": "2025-10-20T09:56:46+00:00"
+ },
{
"name": "laravel/sail",
"version": "v1.46.0",
diff --git a/config/horizon.php b/config/horizon.php
new file mode 100644
index 0000000..1153f9d
--- /dev/null
+++ b/config/horizon.php
@@ -0,0 +1,230 @@
+ env('HORIZON_NAME'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Domain
+ |--------------------------------------------------------------------------
+ |
+ | This is the subdomain where Horizon will be accessible from. If this
+ | setting is null, Horizon will reside under the same domain as the
+ | application. Otherwise, this value will serve as the subdomain.
+ |
+ */
+
+ 'domain' => env('HORIZON_DOMAIN'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Path
+ |--------------------------------------------------------------------------
+ |
+ | This is the URI path where Horizon will be accessible from. Feel free
+ | to change this path to anything you like. Note that the URI will not
+ | affect the paths of its internal API that aren't exposed to users.
+ |
+ */
+
+ 'path' => env('HORIZON_PATH', 'horizon'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Redis Connection
+ |--------------------------------------------------------------------------
+ |
+ | This is the name of the Redis connection where Horizon will store the
+ | meta information required for it to function. It includes the list
+ | of supervisors, failed jobs, job metrics, and other information.
+ |
+ */
+
+ 'use' => 'default',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Redis Prefix
+ |--------------------------------------------------------------------------
+ |
+ | This prefix will be used when storing all Horizon data in Redis. You
+ | may modify the prefix when you are running multiple installations
+ | of Horizon on the same server so that they don't have problems.
+ |
+ */
+
+ 'prefix' => env(
+ 'HORIZON_PREFIX',
+ Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Route Middleware
+ |--------------------------------------------------------------------------
+ |
+ | These middleware will get attached onto each Horizon route, giving you
+ | the chance to add your own middleware to this list or change any of
+ | the existing middleware. Or, you can simply stick with this list.
+ |
+ */
+
+ 'middleware' => ['web'],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Queue Wait Time Thresholds
+ |--------------------------------------------------------------------------
+ |
+ | This option allows you to configure when the LongWaitDetected event
+ | will be fired. Every connection / queue combination may have its
+ | own, unique threshold (in seconds) before this event is fired.
+ |
+ */
+
+ 'waits' => [
+ 'redis:default' => 60,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Job Trimming Times
+ |--------------------------------------------------------------------------
+ |
+ | Here you can configure for how long (in minutes) you desire Horizon to
+ | persist the recent and failed jobs. Typically, recent jobs are kept
+ | for one hour while all failed jobs are stored for an entire week.
+ |
+ */
+
+ 'trim' => [
+ 'recent' => 60,
+ 'pending' => 60,
+ 'completed' => 60,
+ 'recent_failed' => 10080,
+ 'failed' => 10080,
+ 'monitored' => 10080,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Silenced Jobs
+ |--------------------------------------------------------------------------
+ |
+ | Silencing a job will instruct Horizon to not place the job in the list
+ | of completed jobs within the Horizon dashboard. This setting may be
+ | used to fully remove any noisy jobs from the completed jobs list.
+ |
+ */
+
+ 'silenced' => [
+ // App\Jobs\ExampleJob::class,
+ ],
+
+ 'silenced_tags' => [
+ // 'notifications',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Metrics
+ |--------------------------------------------------------------------------
+ |
+ | Here you can configure how many snapshots should be kept to display in
+ | the metrics graph. This will get used in combination with Horizon's
+ | `horizon:snapshot` schedule to define how long to retain metrics.
+ |
+ */
+
+ 'metrics' => [
+ 'trim_snapshots' => [
+ 'job' => 24,
+ 'queue' => 24,
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Fast Termination
+ |--------------------------------------------------------------------------
+ |
+ | When this option is enabled, Horizon's "terminate" command will not
+ | wait on all of the workers to terminate unless the --wait option
+ | is provided. Fast termination can shorten deployment delay by
+ | allowing a new instance of Horizon to start while the last
+ | instance will continue to terminate each of its workers.
+ |
+ */
+
+ 'fast_termination' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Memory Limit (MB)
+ |--------------------------------------------------------------------------
+ |
+ | This value describes the maximum amount of memory the Horizon master
+ | supervisor may consume before it is terminated and restarted. For
+ | configuring these limits on your workers, see the next section.
+ |
+ */
+
+ 'memory_limit' => 64,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Queue Worker Configuration
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define the queue worker settings used by your application
+ | in all environments. These supervisors and settings handle all your
+ | queued jobs and will be provisioned by Horizon during deployment.
+ |
+ */
+
+ 'defaults' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['default'],
+ 'balance' => 'auto',
+ 'autoScalingStrategy' => 'time',
+ 'maxProcesses' => 1,
+ 'maxTime' => 0,
+ 'maxJobs' => 0,
+ 'memory' => 128,
+ 'tries' => 1,
+ 'timeout' => 60,
+ 'nice' => 0,
+ ],
+ ],
+
+ 'environments' => [
+ 'production' => [
+ 'supervisor-1' => [
+ 'maxProcesses' => 10,
+ 'balanceMaxShift' => 1,
+ 'balanceCooldown' => 3,
+ ],
+ ],
+
+ 'local' => [
+ 'supervisor-1' => [
+ 'maxProcesses' => 3,
+ ],
+ ],
+ ],
+];
diff --git a/database/migrations/2025_10_23_103903_add_used_guests_and_gallery_expires_at_to_event_packages_table.php b/database/migrations/2025_10_23_103903_add_used_guests_and_gallery_expires_at_to_event_packages_table.php
new file mode 100644
index 0000000..36ab8e7
--- /dev/null
+++ b/database/migrations/2025_10_23_103903_add_used_guests_and_gallery_expires_at_to_event_packages_table.php
@@ -0,0 +1,40 @@
+timestamp('gallery_expires_at')->nullable();
+ }
+
+ if (! Schema::hasColumn('event_packages', 'used_guests')) {
+ $table->integer('used_guests')->default(0);
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('event_packages', function (Blueprint $table) {
+ if (Schema::hasColumn('event_packages', 'gallery_expires_at')) {
+ $table->dropColumn('gallery_expires_at');
+ }
+
+ if (Schema::hasColumn('event_packages', 'used_guests')) {
+ $table->dropColumn('used_guests');
+ }
+ });
+ }
+};
diff --git a/database/seeders/DemoEventSeeder.php b/database/seeders/DemoEventSeeder.php
index b16109e..e372e41 100644
--- a/database/seeders/DemoEventSeeder.php
+++ b/database/seeders/DemoEventSeeder.php
@@ -2,22 +2,32 @@
namespace Database\Seeders;
-use Illuminate\Database\Seeder;
-use App\Models\{Event, EventType};
+use App\Models\Event;
+use App\Models\EventPackage;
+use App\Models\EventType;
+use App\Models\Package;
+use App\Models\PackagePurchase;
+use App\Models\Tenant;
use App\Services\EventJoinTokenService;
+use Illuminate\Database\Seeder;
+use Illuminate\Support\Facades\Schema;
class DemoEventSeeder extends Seeder
{
public function run(): void
{
- $type = EventType::where('slug','wedding')->first();
- if(!$type){ return; }
- $demoTenant = \App\Models\Tenant::where('slug', 'demo')->first();
- if (!$demoTenant) { return; }
- $event = Event::updateOrCreate(['slug'=>'demo-wedding-2025'], [
+ $type = EventType::where('slug', 'wedding')->first();
+ if (! $type) {
+ return;
+ }
+ $demoTenant = Tenant::where('slug', 'demo-tenant')->first();
+ if (! $demoTenant) {
+ return;
+ }
+ $event = Event::updateOrCreate(['slug' => 'demo-wedding-2025'], [
'tenant_id' => $demoTenant->id,
- 'name' => ['de'=>'Demo Hochzeit 2025','en'=>'Demo Wedding 2025'],
- 'description' => ['de'=>'Demo-Event','en'=>'Demo event'],
+ 'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'],
+ 'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'],
'date' => now()->addMonths(3)->toDateString(),
'event_type_id' => $type->id,
'status' => 'published',
@@ -33,5 +43,43 @@ class DemoEventSeeder extends Seeder
'label' => 'Demo QR',
]);
}
+
+ $package = Package::where('slug', 'standard')->first();
+ if (! $package) {
+ $package = Package::where('type', 'endcustomer')->orderBy('price')->first();
+ }
+
+ if ($package) {
+ $eventPackageData = [
+ 'purchased_price' => $package->price,
+ 'purchased_at' => now()->subDays(7),
+ ];
+
+ if (Schema::hasColumn('event_packages', 'used_photos')) {
+ $eventPackageData['used_photos'] = 0;
+ }
+ if (Schema::hasColumn('event_packages', 'used_guests')) {
+ $eventPackageData['used_guests'] = 0;
+ }
+ if (Schema::hasColumn('event_packages', 'gallery_expires_at')) {
+ $eventPackageData['gallery_expires_at'] = now()->addDays($package->gallery_days ?? 30);
+ }
+
+ EventPackage::updateOrCreate(
+ [
+ 'event_id' => $event->id,
+ 'package_id' => $package->id,
+ ],
+ $eventPackageData
+ );
+
+ PackagePurchase::query()
+ ->where('tenant_id', $demoTenant->id)
+ ->where('package_id', $package->id)
+ ->where('provider_id', 'demo-seed')
+ ->update([
+ 'event_id' => $event->id,
+ ]);
+ }
}
}
diff --git a/database/seeders/DemoLifecycleSeeder.php b/database/seeders/DemoLifecycleSeeder.php
new file mode 100644
index 0000000..7e03db1
--- /dev/null
+++ b/database/seeders/DemoLifecycleSeeder.php
@@ -0,0 +1,404 @@
+ensurePackages();
+ [$weddingType, $corporateType] = $this->ensureEventTypes();
+
+ $this->seedOnboardingTenant();
+ $this->seedActiveTenant($standard, $premium, $weddingType, $corporateType);
+ $this->seedResellerTenant($reseller, $standard, $weddingType);
+ $this->seedDormantTenant();
+ }
+
+ private function ensurePackages(): array
+ {
+ $standard = Package::firstOrCreate(
+ ['slug' => 'standard'],
+ [
+ 'type' => 'endcustomer',
+ 'name' => 'Standard',
+ 'name_translations' => ['de' => 'Standard', 'en' => 'Standard'],
+ 'price' => 79,
+ 'max_photos' => 1500,
+ 'max_guests' => 400,
+ 'gallery_days' => 60,
+ 'features' => [
+ 'basic_uploads' => true,
+ 'unlimited_sharing' => true,
+ 'no_watermark' => true,
+ 'custom_tasks' => true,
+ ],
+ ]
+ );
+
+ $premium = Package::firstOrCreate(
+ ['slug' => 'premium'],
+ [
+ 'type' => 'endcustomer',
+ 'name' => 'Premium',
+ 'name_translations' => ['de' => 'Premium', 'en' => 'Premium'],
+ 'price' => 149,
+ 'max_photos' => 5000,
+ 'max_guests' => 1000,
+ 'gallery_days' => 180,
+ 'features' => [
+ 'basic_uploads' => true,
+ 'unlimited_sharing' => true,
+ 'no_watermark' => true,
+ 'custom_branding' => true,
+ 'custom_tasks' => true,
+ ],
+ ]
+ );
+
+ $reseller = Package::firstOrCreate(
+ ['slug' => 'studio-annual'],
+ [
+ 'type' => 'reseller',
+ 'name' => 'Studio Jahrespaket',
+ 'name_translations' => ['de' => 'Studio Jahrespaket', 'en' => 'Studio Annual'],
+ 'price' => 1299,
+ 'max_events_per_year' => 24,
+ 'features' => [
+ 'custom_branding' => true,
+ 'unlimited_sharing' => true,
+ 'basic_uploads' => true,
+ ],
+ ]
+ );
+
+ return [$standard, $premium, $reseller];
+ }
+
+ private function ensureEventTypes(): array
+ {
+ $weddingType = EventType::firstOrCreate(
+ ['slug' => 'wedding'],
+ [
+ 'name' => 'Wedding',
+ 'name_translations' => ['de' => 'Hochzeit', 'en' => 'Wedding'],
+ 'icon' => 'heart',
+ ]
+ );
+
+ $corporateType = EventType::firstOrCreate(
+ ['slug' => 'corporate'],
+ [
+ 'name' => 'Corporate Event',
+ 'name_translations' => ['de' => 'Firmen-Event', 'en' => 'Corporate'],
+ 'icon' => 'presentation-chart',
+ ]
+ );
+
+ return [$weddingType, $corporateType];
+ }
+
+ private function seedOnboardingTenant(): void
+ {
+ $tenant = $this->createTenant('storycraft-weddings', [
+ 'name' => 'Storycraft Weddings',
+ 'contact_email' => 'hello@storycraft-weddings.demo',
+ 'event_credits_balance' => 0,
+ 'subscription_tier' => 'free',
+ 'subscription_status' => 'free',
+ 'subscription_expires_at' => null,
+ 'is_active' => true,
+ ]);
+
+ $this->createTenantAdmin($tenant, 'storycraft-owner@demo.fotospiel');
+ $this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-storycraft');
+ }
+
+ private function seedActiveTenant(Package $standard, Package $premium, EventType $weddingType, EventType $corporateType): void
+ {
+ $tenant = $this->createTenant('lumen-moments', [
+ 'name' => 'Lumen Moments',
+ 'contact_email' => 'hello@lumen-moments.demo',
+ 'event_credits_balance' => 2,
+ 'subscription_tier' => 'starter',
+ 'subscription_status' => 'active',
+ 'is_active' => true,
+ ]);
+
+ OAuthClient::query()
+ ->where('client_id', config('services.oauth.tenant_admin.id', 'tenant-admin-app'))
+ ->update(['tenant_id' => $tenant->id]);
+
+ $this->createTenantAdmin($tenant, 'hello@lumen-moments.demo');
+ $this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-lumen');
+
+ $purchase = PackagePurchase::create([
+ 'tenant_id' => $tenant->id,
+ 'package_id' => $premium->id,
+ 'provider_id' => 'stripe_demo_pi',
+ 'price' => $premium->price,
+ 'type' => 'endcustomer_event',
+ 'purchased_at' => Carbon::now()->subDays(3),
+ 'metadata' => ['demo' => true],
+ ]);
+
+ $publishedEvent = $this->createEventWithPackage(
+ tenant: $tenant,
+ package: $premium,
+ eventType: $weddingType,
+ attributes: [
+ 'name' => ['de' => 'Sommerhochzeit Lea & Tim', 'en' => 'Summer Wedding Lea & Tim'],
+ 'slug' => 'summer-wedding-lea-tim',
+ 'status' => 'published',
+ 'is_active' => true,
+ 'date' => Carbon::now()->addWeeks(4),
+ ]
+ );
+
+ $draftEvent = $this->createEventWithPackage(
+ tenant: $tenant,
+ package: $standard,
+ eventType: $corporateType,
+ attributes: [
+ 'name' => ['de' => 'Startup Social 2025', 'en' => 'Startup Social 2025'],
+ 'slug' => 'startup-social-2025',
+ 'status' => 'draft',
+ 'is_active' => false,
+ 'date' => Carbon::now()->addWeeks(12),
+ ]
+ );
+
+ $purchase->update(['event_id' => $publishedEvent->id]);
+
+ PackagePurchase::create([
+ 'tenant_id' => $tenant->id,
+ 'event_id' => $draftEvent->id,
+ 'package_id' => $standard->id,
+ 'provider_id' => 'paypal_demo_capture',
+ 'price' => $standard->price,
+ 'type' => 'endcustomer_event',
+ 'purchased_at' => Carbon::now()->subDays(1),
+ 'metadata' => ['demo' => true],
+ ]);
+ }
+
+ private function seedResellerTenant(Package $reseller, Package $standard, EventType $weddingType): void
+ {
+ $tenant = $this->createTenant('viewfinder-studios', [
+ 'name' => 'Viewfinder Studios',
+ 'contact_email' => 'team@viewfinder.demo',
+ 'event_credits_balance' => 0,
+ 'subscription_tier' => 'reseller',
+ 'subscription_status' => 'active',
+ 'is_active' => true,
+ ]);
+
+ $this->createTenantAdmin($tenant, 'team@viewfinder.demo');
+ $this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-viewfinder');
+
+ $tenantPackage = TenantPackage::create([
+ 'tenant_id' => $tenant->id,
+ 'package_id' => $reseller->id,
+ 'price' => $reseller->price,
+ 'purchased_at' => Carbon::now()->subMonths(2),
+ 'expires_at' => Carbon::now()->addMonths(10),
+ 'used_events' => 6,
+ 'active' => true,
+ ]);
+
+ PackagePurchase::create([
+ 'tenant_id' => $tenant->id,
+ 'package_id' => $reseller->id,
+ 'provider_id' => 'stripe_demo_subscription',
+ 'price' => $reseller->price,
+ 'type' => 'reseller_subscription',
+ 'purchased_at' => $tenantPackage->purchased_at,
+ 'metadata' => ['demo' => true, 'plan' => 'studio-annual'],
+ ]);
+
+ // Create a mix of events representing allowance consumption.
+ $statuses = ['published', 'published', 'draft', 'archived'];
+
+ foreach ($statuses as $index => $status) {
+ $event = $this->createEventWithPackage(
+ tenant: $tenant,
+ package: $standard,
+ eventType: $weddingType,
+ attributes: [
+ 'name' => ['de' => 'Studio Event #'.($index + 1), 'en' => 'Studio Event #'.($index + 1)],
+ 'slug' => 'studio-event-'.($index + 1),
+ 'status' => $status,
+ 'is_active' => $status === 'published',
+ 'date' => $status === 'archived'
+ ? Carbon::now()->subMonths(3)
+ : Carbon::now()->addWeeks($index * 3 + 1),
+ ]
+ );
+
+ PackagePurchase::create([
+ 'tenant_id' => $tenant->id,
+ 'event_id' => $event->id,
+ 'package_id' => $standard->id,
+ 'provider_id' => 'reseller_allowance',
+ 'price' => 0,
+ 'type' => 'endcustomer_event',
+ 'purchased_at' => Carbon::now()->subDays(10 - $index),
+ 'metadata' => ['allowance' => true],
+ ]);
+ }
+ }
+
+ private function seedDormantTenant(): void
+ {
+ $tenant = $this->createTenant('pixel-and-co', [
+ 'name' => 'Pixel & Co',
+ 'contact_email' => 'support@pixelco.demo',
+ 'subscription_status' => 'expired',
+ 'subscription_tier' => 'free',
+ 'subscription_expires_at' => Carbon::now()->subMonths(2),
+ 'is_active' => false,
+ 'is_suspended' => false,
+ 'event_credits_balance' => 0,
+ ]);
+
+ $this->createTenantAdmin($tenant, 'support@pixelco.demo', role: 'member');
+ $this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-pixel');
+ }
+
+ private function createTenantAdmin(Tenant $tenant, string $email, string $role = 'tenant_admin'): User
+ {
+ return User::updateOrCreate(
+ ['email' => $email],
+ [
+ 'tenant_id' => $tenant->id,
+ 'role' => $role,
+ 'password' => Hash::make('Demo1234!'),
+ 'first_name' => Str::headline(Str::before($tenant->slug, '-')),
+ 'last_name' => 'Team',
+ ]
+ );
+ }
+
+ private function createEventWithPackage(
+ Tenant $tenant,
+ Package $package,
+ EventType $eventType,
+ array $attributes
+ ): Event {
+ $payload = array_merge([
+ 'tenant_id' => $tenant->id,
+ 'event_type_id' => $eventType->id,
+ 'settings' => [
+ 'features' => [
+ 'photo_likes_enabled' => true,
+ 'event_checklist' => true,
+ ],
+ ],
+ ], $attributes);
+
+ $event = Event::updateOrCreate(['slug' => $attributes['slug']], $payload);
+
+ EventPackage::updateOrCreate(
+ [
+ 'event_id' => $event->id,
+ 'package_id' => $package->id,
+ ],
+ [
+ 'purchased_price' => $package->price,
+ 'purchased_at' => Carbon::now()->subDays(2),
+ 'used_photos' => 0,
+ 'used_guests' => 0,
+ 'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30),
+ ]
+ );
+
+ return $event;
+ }
+
+ private function createTenant(string $slug, array $overrides = []): Tenant
+ {
+ $email = $overrides['contact_email'] ?? $slug.'@demo.fotospiel';
+
+ $defaults = [
+ 'name' => Str::headline(str_replace('-', ' ', $slug)),
+ 'slug' => $slug,
+ 'contact_email' => $email,
+ 'event_credits_balance' => 0,
+ 'subscription_tier' => 'free',
+ 'subscription_status' => 'free',
+ 'subscription_expires_at' => Carbon::now()->addMonths(6),
+ 'is_active' => true,
+ 'is_suspended' => false,
+ 'settings_updated_at' => Carbon::now(),
+ 'settings' => $this->defaultSettings($email),
+ ];
+
+ $attributes = array_merge($defaults, $overrides);
+
+ $tenant = Tenant::updateOrCreate(['slug' => $slug], $attributes);
+
+ return $tenant;
+ }
+
+ private function defaultSettings(string $contactEmail): array
+ {
+ return [
+ 'branding' => [
+ 'logo_url' => null,
+ 'primary_color' => '#3B82F6',
+ 'secondary_color' => '#1F2937',
+ 'font_family' => 'Inter, sans-serif',
+ ],
+ 'features' => [
+ 'photo_likes_enabled' => true,
+ 'event_checklist' => true,
+ 'custom_domain' => false,
+ 'advanced_analytics' => false,
+ ],
+ 'custom_domain' => null,
+ 'contact_email' => $contactEmail,
+ 'event_default_type' => 'general',
+ ];
+ }
+
+ private function ensureOAuthClientForTenant(Tenant $tenant, string $clientId): void
+ {
+ $redirectUris = config('services.oauth.tenant_admin.redirects', []);
+ if (empty($redirectUris)) {
+ $redirectUris = [
+ 'http://localhost:5173/event-admin/auth/callback',
+ url('/event-admin/auth/callback'),
+ ];
+ }
+
+ $client = OAuthClient::firstOrNew(['client_id' => $clientId]);
+
+ if (! $client->exists) {
+ $client->id = (string) Str::uuid();
+ }
+
+ $client->fill([
+ 'client_secret' => null,
+ 'tenant_id' => $tenant->id,
+ 'redirect_uris' => $redirectUris,
+ 'scopes' => ['tenant:read', 'tenant:write'],
+ 'is_active' => true,
+ ]);
+
+ $client->save();
+ }
+}
diff --git a/database/seeders/DemoTenantSeeder.php b/database/seeders/DemoTenantSeeder.php
index e511d9c..be7997d 100644
--- a/database/seeders/DemoTenantSeeder.php
+++ b/database/seeders/DemoTenantSeeder.php
@@ -2,6 +2,7 @@
namespace Database\Seeders;
+use App\Models\EventPurchase;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
@@ -18,10 +19,19 @@ class DemoTenantSeeder extends Seeder
$email = 'tenant-demo@fotospiel.app';
$password = config('seeding.demo_tenant_password', 'Demo1234!');
$package = Package::query()
- ->where('type', 'reseller')
- ->orderBy('price')
- ->first()
- ?? Package::query()->orderBy('price')->first();
+ ->where('slug', 'standard')
+ ->first();
+
+ if (! $package) {
+ $package = Package::query()
+ ->where('type', 'endcustomer')
+ ->orderBy('price')
+ ->first();
+ }
+
+ if (! $package) {
+ $package = Package::query()->orderBy('price')->first();
+ }
if (! $package) {
$this->command?->warn('Skipped DemoTenantSeeder: no packages available.');
@@ -75,13 +85,15 @@ class DemoTenantSeeder extends Seeder
);
if ($tenant->wasRecentlyCreated && ! $tenant->slug) {
- $tenant->forceFill(['slug' => Str::slug('demo-tenant-'. $tenant->getKey())])->save();
+ $tenant->forceFill(['slug' => Str::slug('demo-tenant-'.$tenant->getKey())])->save();
}
if ($user->tenant_id !== $tenant->id) {
$user->forceFill(['tenant_id' => $tenant->id])->save();
}
+ $purchasedAt = now()->subDays(7);
+
TenantPackage::query()->updateOrCreate(
[
'tenant_id' => $tenant->id,
@@ -90,8 +102,8 @@ class DemoTenantSeeder extends Seeder
[
'price' => $package->price,
'active' => true,
- 'purchased_at' => now()->subDays(7),
- 'expires_at' => now()->addYear(),
+ 'purchased_at' => $purchasedAt,
+ 'expires_at' => now()->addMonths(6),
'used_events' => 0,
]
);
@@ -103,13 +115,30 @@ class DemoTenantSeeder extends Seeder
'provider_id' => 'demo-seed',
],
[
+ 'event_id' => null,
'price' => $package->price,
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
- 'purchased_at' => now()->subDays(7),
+ 'purchased_at' => $purchasedAt,
'metadata' => [
'seeded' => true,
'note' => 'Demo tenant seed purchase',
],
+ 'ip_address' => null,
+ 'user_agent' => null,
+ ]
+ );
+
+ EventPurchase::query()->updateOrCreate(
+ [
+ 'tenant_id' => $tenant->id,
+ 'provider' => 'demo-seed',
+ ],
+ [
+ 'events_purchased' => 1,
+ 'amount' => $package->price,
+ 'currency' => 'EUR',
+ 'status' => 'completed',
+ 'purchased_at' => $purchasedAt,
]
);
diff --git a/docs/deployment/lokale podman adressen.md b/docs/deployment/lokale podman adressen.md
new file mode 100644
index 0000000..1a40934
--- /dev/null
+++ b/docs/deployment/lokale podman adressen.md
@@ -0,0 +1,13 @@
+ Services & URLs
+
+ | Service | URL / Port | Notes |
+ |----------------|--------------------------------|-------|
+ | Laravel app | http://localhost:8000 | Default web UI; Horizon dashboard at /horizon if laravel/horizon is installed. |
+ | Vite dev server| http://localhost:5173 | Hot module reload for the marketing/guest frontend.
+ |
+ | Mailpit UI | http://localhost:8025 | No auth; SMTP listening on port 1025. |
+ | Grafana | http://localhost:3000 | Anonymous admin already enabled; dashboards will show Loki logs once you add Loki as a data source (URL http://loki:3100). |
+ | Loki API | http://localhost:3100 | Used by Grafana/Promtail; direct browsing usually not needed. |
+ | Portainer | https://localhost:9443 | First visit prompts you to set an admin password; point it to /var/run/docker.sock (already mounted from ${PODMAN_SOCKET}). |
+ | Redis | Bound to localhost:6379 | Matches QUEUE_CONNECTION=redis. |
+ | Promtail | Internal only (port 9080) | Tails storage/logs and pushes to Loki. |
\ No newline at end of file
diff --git a/google-chrome-stable_current_amd64.deb b/google-chrome-stable_current_amd64.deb
new file mode 100644
index 0000000..b25c387
Binary files /dev/null and b/google-chrome-stable_current_amd64.deb differ
diff --git a/package-lock.json b/package-lock.json
index cbc9d7d..6903a40 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,6 +36,7 @@
"@types/react-dom": "^19.0.2",
"@upstash/context7-mcp": "^1.0.21",
"@vitejs/plugin-react": "^4.6.0",
+ "chrome-devtools-mcp": "^0.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
@@ -5507,6 +5508,18 @@
"node": ">=18"
}
},
+ "node_modules/chrome-devtools-mcp": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-0.9.0.tgz",
+ "integrity": "sha512-7MzI/fdnwbKHzgnGWUmCyEYdKnSpfSIelDV9XNTz8wrjycoMB6cENryKLyZkLHXkZLlDdOLfYa9YtF+3lQoM2g==",
+ "license": "Apache-2.0",
+ "bin": {
+ "chrome-devtools-mcp": "build/src/index.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
"node_modules/chromium-bidi": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz",
diff --git a/package.json b/package.json
index e695da5..a94b9c8 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
"@types/react-dom": "^19.0.2",
"@upstash/context7-mcp": "^1.0.21",
"@vitejs/plugin-react": "^4.6.0",
+ "chrome-devtools-mcp": "^0.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..3aa33ab
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
index 94e1ad8..66c9913 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -10,6 +10,7 @@ function getDisplayValue(value: string | undefined) {
export default defineConfig({
testDir: './tests/e2e',
+ timeout: 90_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
@@ -29,4 +30,4 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
-});
\ No newline at end of file
+});
diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts
index f8de96b..3cd93c7 100644
--- a/resources/js/admin/api.ts
+++ b/resources/js/admin/api.ts
@@ -18,16 +18,37 @@ export type EventJoinTokenLayout = {
download_urls: Record;
};
+export type TenantEventType = {
+ id: number;
+ slug: string;
+ name: string;
+ name_translations: Record;
+ icon: string | null;
+ settings: Record;
+ created_at?: string | null;
+ updated_at?: string | null;
+};
+
export type TenantEvent = {
id: number;
name: string | Record;
slug: string;
event_date: string | null;
+ event_type_id: number | null;
+ event_type: TenantEventType | null;
status: 'draft' | 'published' | 'archived';
is_active?: boolean;
description?: string | null;
photo_count?: number;
like_count?: number;
+ package?: {
+ id: number | string | null;
+ name: string | null;
+ price: number | null;
+ purchased_at: string | null;
+ expires_at: string | null;
+ } | null;
+ [key: string]: unknown;
};
export type TenantPhoto = {
@@ -208,8 +229,8 @@ export type EventMember = {
avatar_url?: string | null;
};
-type EventListResponse = { data?: TenantEvent[] };
-type EventResponse = { data: TenantEvent };
+type EventListResponse = { data?: JsonValue[] };
+type EventResponse = { data: JsonValue };
export type EventJoinToken = {
id: number;
@@ -226,13 +247,14 @@ export type EventJoinToken = {
layouts: EventJoinTokenLayout[];
layouts_url: string | null;
};
-type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
+type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto };
type EventSavePayload = {
name: string;
slug: string;
- date?: string;
+ event_type_id: number;
+ event_date?: string;
status?: 'draft' | 'published' | 'archived';
is_active?: boolean;
package_id?: number;
@@ -322,13 +344,51 @@ function pickTranslatedText(translations: Record, fallback: stri
return fallback;
}
-function normalizeEvent(event: TenantEvent): TenantEvent {
+function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEventType | null {
+ if (!raw) {
+ return null;
+ }
+
+ const translations = normalizeTranslationMap((raw as JsonValue).name ?? {}, undefined, true);
+ const fallback = typeof (raw as JsonValue).name === 'string' ? (raw as JsonValue).name : 'Event';
+
return {
- ...event,
- is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined,
+ id: Number((raw as JsonValue).id ?? 0),
+ slug: String((raw as JsonValue).slug ?? ''),
+ name: pickTranslatedText(translations, fallback ?? 'Event'),
+ name_translations: translations,
+ icon: ((raw as JsonValue).icon ?? null) as string | null,
+ settings: ((raw as JsonValue).settings ?? {}) as Record,
+ created_at: (raw as JsonValue).created_at ?? null,
+ updated_at: (raw as JsonValue).updated_at ?? null,
};
}
+function normalizeEvent(event: JsonValue): TenantEvent {
+ const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
+ const normalized: TenantEvent = {
+ ...(event as Record),
+ id: Number(event.id ?? 0),
+ name: event.name ?? '',
+ slug: String(event.slug ?? ''),
+ event_date: typeof event.event_date === 'string'
+ ? event.event_date
+ : (typeof event.date === 'string' ? event.date : null),
+ event_type_id: event.event_type_id !== undefined && event.event_type_id !== null
+ ? Number(event.event_type_id)
+ : null,
+ event_type: normalizedType,
+ status: (event.status ?? 'draft') as TenantEvent['status'],
+ is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined,
+ description: event.description ?? null,
+ photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
+ like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
+ package: event.package ?? null,
+ };
+
+ return normalized;
+}
+
function normalizePhoto(photo: TenantPhoto): TenantPhoto {
return {
id: photo.id,
@@ -574,6 +634,15 @@ export async function getEvent(slug: string): Promise {
return normalizeEvent(data.data);
}
+export async function getEventTypes(): Promise {
+ const response = await authorizedFetch('/api/v1/tenant/event-types');
+ const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types');
+ const rows = Array.isArray(data.data) ? data.data : [];
+ return rows
+ .map((row) => normalizeEventType(row))
+ .filter((row): row is TenantEventType => Boolean(row));
+}
+
export async function getEventPhotos(slug: string): Promise {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`);
const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos');
@@ -602,7 +671,7 @@ export async function deletePhoto(slug: string, id: number): Promise {
export async function toggleEvent(slug: string): Promise {
const response = await authorizedFetch(`${eventEndpoint(slug)}/toggle`, { method: 'POST' });
- const data = await jsonOrThrow<{ message: string; data: TenantEvent }>(response, 'Failed to toggle event');
+ const data = await jsonOrThrow<{ message: string; data: JsonValue }>(response, 'Failed to toggle event');
return normalizeEvent(data.data);
}
@@ -621,7 +690,7 @@ export async function getEventStats(slug: string): Promise {
export async function getEventJoinTokens(slug: string): Promise {
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
- const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load join tokens');
+ const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
const list = Array.isArray(payload.data) ? payload.data : [];
return list.map(normalizeJoinToken);
}
@@ -636,7 +705,7 @@ export async function createInviteLink(
headers: { 'Content-Type': 'application/json' },
body,
});
- const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invite');
+ const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
return normalizeJoinToken(data.data ?? {});
}
@@ -651,7 +720,7 @@ export async function revokeEventJoinToken(
options.body = JSON.stringify({ reason });
}
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
- const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke join token');
+ const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
return normalizeJoinToken(data.data ?? {});
}
diff --git a/resources/js/admin/components/DevTenantSwitcher.tsx b/resources/js/admin/components/DevTenantSwitcher.tsx
new file mode 100644
index 0000000..3947d35
--- /dev/null
+++ b/resources/js/admin/components/DevTenantSwitcher.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { Loader2 } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+
+const DEV_TENANT_KEYS = [
+ { key: 'lumen', label: 'Lumen Moments' },
+ { key: 'storycraft', label: 'Storycraft Weddings' },
+ { key: 'viewfinder', label: 'Viewfinder Studios' },
+ { key: 'pixel', label: 'Pixel & Co (dormant)' },
+] as const;
+
+declare global {
+ interface Window {
+ fotospielDemoAuth?: {
+ clients: Record;
+ loginAs: (tenantKey: string) => Promise;
+ };
+ }
+}
+
+export function DevTenantSwitcher() {
+ const helper = window.fotospielDemoAuth;
+ const [loggingIn, setLoggingIn] = React.useState(null);
+
+ if (!helper) {
+ return null;
+ }
+
+ async function handleLogin(key: string) {
+ if (!helper) return;
+ setLoggingIn(key);
+ try {
+ await helper.loginAs(key);
+ } catch (error) {
+ console.error('[DevAuth] Switch failed', error);
+ setLoggingIn(null);
+ }
+ }
+
+ return (
+
+
+ Demo tenants
+ Dev mode
+
+
+ Select a seeded tenant to mint OAuth tokens and jump straight into their admin space. Available only in development builds.
+
+
+ {DEV_TENANT_KEYS.map(({ key, label }) => (
+ void handleLogin(key)}
+ >
+ {loggingIn === key ? (
+ <>
+
+ Verbinde...
+ >
+ ) : (
+ label
+ )}
+
+ ))}
+
+
+ Console: fotospielDemoAuth.loginAs('lumen')
+
+
+ );
+}
diff --git a/resources/js/admin/dev-tools.ts b/resources/js/admin/dev-tools.ts
new file mode 100644
index 0000000..2032f41
--- /dev/null
+++ b/resources/js/admin/dev-tools.ts
@@ -0,0 +1,145 @@
+if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true') {
+ type StoredTokens = {
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: number;
+ scope?: string;
+ };
+
+ const CLIENTS: Record = {
+ lumen: import.meta.env.VITE_OAUTH_CLIENT_ID || 'tenant-admin-app',
+ storycraft: 'demo-tenant-admin-storycraft',
+ viewfinder: 'demo-tenant-admin-viewfinder',
+ pixel: 'demo-tenant-admin-pixel',
+ };
+
+ const scopes = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
+ const baseUrl = window.location.origin;
+ const redirectUri = `${baseUrl}/event-admin/auth/callback`;
+
+ async function loginAs(label: string): Promise {
+ const clientId = CLIENTS[label];
+ if (!clientId) {
+ console.warn('[DevAuth] Unknown tenant key', label);
+ return;
+ }
+
+ try {
+ const tokens = await fetchTokens(clientId);
+ localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
+ window.location.assign('/event-admin/dashboard');
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error('[DevAuth] Failed to login', error.message);
+ } else {
+ console.error('[DevAuth] Failed to login', error);
+ }
+ }
+ }
+
+ async function fetchTokens(clientId: string): Promise {
+ const verifier = randomString(32);
+ const challenge = await sha256(verifier);
+ const state = randomString(12);
+
+ const authorizeParams = new URLSearchParams({
+ response_type: 'code',
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ scope: scopes,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+
+ const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`);
+ verifyState(callbackUrl.searchParams.get('state'), state);
+
+ const code = callbackUrl.searchParams.get('code');
+ if (!code) {
+ throw new Error('Authorize response missing code');
+ }
+
+ const tokenResponse = await fetch('/api/v1/oauth/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ code_verifier: verifier,
+ }),
+ });
+
+ if (!tokenResponse.ok) {
+ throw new Error(`Token exchange failed with ${tokenResponse.status}`);
+ }
+
+ const body = await tokenResponse.json();
+ const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
+
+ return {
+ accessToken: body.access_token,
+ refreshToken: body.refresh_token,
+ expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
+ scope: body.scope,
+ };
+ }
+
+ function randomString(bytes: number): string {
+ const buffer = new Uint8Array(bytes);
+ crypto.getRandomValues(buffer);
+ return base64Url(buffer);
+ }
+
+ async function sha256(input: string): Promise {
+ const encoder = new TextEncoder();
+ const data = encoder.encode(input);
+ const digest = await crypto.subtle.digest('SHA-256', data);
+ return base64Url(new Uint8Array(digest));
+ }
+
+ function base64Url(data: Uint8Array): string {
+ const binary = Array.from(data, (byte) => String.fromCharCode(byte)).join('');
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
+ }
+
+ const api = { loginAs, clients: CLIENTS };
+
+ console.info('[DevAuth] Demo tenant helpers ready', Object.keys(CLIENTS));
+
+ // @ts-expect-error Dev helper for debugging only.
+ window.fotospielDemoAuth = api;
+ // @ts-expect-error Dev helper for debugging only.
+ globalThis.fotospielDemoAuth = api;
+}
+
+function requestAuthorization(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('GET', url, true);
+ xhr.withCredentials = true;
+ xhr.onreadystatechange = () => {
+ if (xhr.readyState !== XMLHttpRequest.DONE) {
+ return;
+ }
+
+ const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
+ if (xhr.status >= 200 && xhr.status < 400 && responseUrl) {
+ resolve(new URL(responseUrl, window.location.origin));
+ return;
+ }
+
+ reject(new Error(`Authorize failed with ${xhr.status}`));
+ };
+ xhr.onerror = () => reject(new Error('Authorize request failed'));
+ xhr.send();
+ });
+}
+
+function verifyState(returnedState: string | null, expectedState: string): void {
+ if (returnedState && returnedState !== expectedState) {
+ throw new Error('Authorize state mismatch');
+ }
+}
diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json
index 637c3e7..067bf95 100644
--- a/resources/js/admin/i18n/locales/de/dashboard.json
+++ b/resources/js/admin/i18n/locales/de/dashboard.json
@@ -7,7 +7,7 @@
"welcome": {
"fallbackName": "Tenant-Admin",
"greeting": "Hallo {{name}}!",
- "subtitle": "Behalte deine Events, Credits und Aufgaben im Blick."
+ "subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
},
"errors": {
"loadFailed": "Dashboard konnte nicht geladen werden."
@@ -27,6 +27,7 @@
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
"noPackage": "Kein aktives Paket",
"stats": {
+ "activePackage": "Aktives Paket",
"activeEvents": "Aktive Events",
"publishedHint": "{{count}} veröffentlicht",
"newPhotos": "Neue Fotos (7 Tage)",
@@ -50,9 +51,9 @@
"label": "Tasks organisieren",
"description": "Sorge für klare Verantwortungen."
},
- "manageCredits": {
- "label": "Credits verwalten",
- "description": "Sieh dir Balance & Ledger an."
+ "managePackages": {
+ "label": "Pakete verwalten",
+ "description": "Aktives Paket und Historie einsehen."
}
},
"upcoming": {
@@ -68,5 +69,78 @@
"planning": "In Planung",
"noDate": "Kein Datum"
}
+ },
+ "dashboard": {
+ "actions": {
+ "newEvent": "Neues Event",
+ "allEvents": "Alle Events",
+ "guidedSetup": "Guided Setup"
+ },
+ "welcome": {
+ "fallbackName": "Tenant-Admin",
+ "greeting": "Hallo {{name}}!",
+ "subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
+ },
+ "errors": {
+ "loadFailed": "Dashboard konnte nicht geladen werden."
+ },
+ "alerts": {
+ "errorTitle": "Fehler"
+ },
+ "welcomeCard": {
+ "title": "Starte mit der Welcome Journey",
+ "summary": "Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit geführten Schritten.",
+ "body1": "Wir begleiten dich durch Pakete, Aufgaben und Galerie-Konfiguration, damit dein Event glänzt.",
+ "body2": "Du kannst jederzeit zur Welcome Journey zurückkehren, auch wenn bereits Events laufen.",
+ "cta": "Jetzt starten"
+ },
+ "overview": {
+ "title": "Kurzer Überblick",
+ "description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
+ "noPackage": "Kein aktives Paket",
+ "stats": {
+ "activePackage": "Aktives Paket",
+ "activeEvents": "Aktive Events",
+ "publishedHint": "{{count}} veröffentlicht",
+ "newPhotos": "Neue Fotos (7 Tage)",
+ "taskProgress": "Task-Fortschritt",
+ "credits": "Credits",
+ "lowCredits": "Auffüllen empfohlen"
+ }
+ },
+ "quickActions": {
+ "title": "Schnellaktionen",
+ "description": "Starte durch mit den wichtigsten Aktionen.",
+ "createEvent": {
+ "label": "Event erstellen",
+ "description": "Plane dein nächstes Highlight."
+ },
+ "moderatePhotos": {
+ "label": "Fotos moderieren",
+ "description": "Prüfe neue Uploads."
+ },
+ "organiseTasks": {
+ "label": "Tasks organisieren",
+ "description": "Sorge für klare Verantwortungen."
+ },
+ "managePackages": {
+ "label": "Pakete verwalten",
+ "description": "Aktives Paket und Historie einsehen."
+ }
+ },
+ "upcoming": {
+ "title": "Kommende Events",
+ "description": "Die nächsten Termine inklusive Status & Zugriff.",
+ "settings": "Einstellungen öffnen",
+ "empty": {
+ "message": "Noch keine Termine geplant. Lege dein erstes Event an!",
+ "cta": "Event planen"
+ },
+ "status": {
+ "live": "Live",
+ "planning": "In Planung",
+ "noDate": "Kein Datum"
+ }
+ }
}
}
diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json
index 735a5cd..135eda1 100644
--- a/resources/js/admin/i18n/locales/de/management.json
+++ b/resources/js/admin/i18n/locales/de/management.json
@@ -1,22 +1,25 @@
{
"billing": {
- "title": "Billing und Credits",
- "subtitle": "Verwalte Guthaben, Pakete und Abrechnungen.",
+ "title": "Pakete & Abrechnung",
+ "subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
"actions": {
"refresh": "Aktualisieren",
"exportCsv": "Export als CSV"
},
"errors": {
- "load": "Billing-Daten konnten nicht geladen werden.",
- "more": "Weitere Ledger-Einträge konnten nicht geladen werden."
+ "load": "Paketdaten konnten nicht geladen werden.",
+ "more": "Weitere Einträge konnten nicht geladen werden."
},
"sections": {
"overview": {
- "title": "Credits und Status",
- "description": "Dein aktuelles Guthaben und das aktive Reseller-Paket.",
+ "title": "Paketübersicht",
+ "description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
+ "empty": "Noch kein Paket aktiv.",
+ "emptyBadge": "Kein aktives Paket",
"cards": {
- "balance": {
- "label": "Verfügbare Credits"
+ "package": {
+ "label": "Aktives Paket",
+ "helper": "Aktuell zugewiesen"
},
"used": {
"label": "Genutzte Events",
@@ -26,34 +29,35 @@
"label": "Preis (netto)"
},
"expires": {
- "label": "Ablauf",
- "helper": "Automatisch verlängern, falls aktiv"
+ "label": "Läuft ab",
+ "helper": "Automatische Verlängerung, falls aktiv"
}
}
},
"packages": {
"title": "Paket-Historie",
- "description": "Übersicht über aktive und vergangene Reseller-Pakete.",
+ "description": "Übersicht über aktive und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.",
"card": {
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"used": "Genutzte Events",
"available": "Verfügbar",
- "expires": "Ablauf"
- }
- },
- "ledger": {
- "title": "Credit Ledger",
- "description": "Alle Zu- und Abbuchungen deines Credits-Kontos.",
- "empty": "Noch keine Ledger-Einträge vorhanden.",
- "loadMore": "Mehr laden",
- "reasons": {
- "purchase": "Credit-Kauf",
- "usage": "Verbrauch",
- "manual": "Manuelle Anpassung"
+ "expires": "Läuft ab"
}
}
+ },
+ "packages": {
+ "title": "Paket-Historie",
+ "description": "Übersicht über aktive und vergangene Pakete.",
+ "empty": "Noch keine Pakete gebucht.",
+ "card": {
+ "statusActive": "Aktiv",
+ "statusInactive": "Inaktiv",
+ "used": "Genutzte Events",
+ "available": "Verfügbar",
+ "expires": "Läuft ab"
+ }
}
},
"members": {
@@ -241,5 +245,57 @@
"submit": "Emotion speichern"
}
}
+ ,
+ "management": {
+ "billing": {
+ "title": "Pakete & Abrechnung",
+ "subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
+ "actions": {
+ "refresh": "Aktualisieren",
+ "exportCsv": "Export als CSV"
+ },
+ "errors": {
+ "load": "Paketdaten konnten nicht geladen werden.",
+ "more": "Weitere Einträge konnten nicht geladen werden."
+ },
+ "sections": {
+ "overview": {
+ "title": "Paketübersicht",
+ "description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
+ "empty": "Noch kein Paket aktiv.",
+ "emptyBadge": "Kein aktives Paket",
+ "cards": {
+ "package": {
+ "label": "Aktives Paket",
+ "helper": "Aktuell zugewiesen"
+ },
+ "used": {
+ "label": "Genutzte Events",
+ "helper": "Verfügbar: {{count}}"
+ },
+ "price": {
+ "label": "Preis (netto)"
+ },
+ "expires": {
+ "label": "Läuft ab",
+ "helper": "Automatische Verlängerung, falls aktiv"
+ }
+ }
+ }
+ },
+ "packages": {
+ "title": "Paket-Historie",
+ "description": "Übersicht über aktuelle und vergangene Pakete.",
+ "empty": "Noch keine Pakete gebucht.",
+ "card": {
+ "statusActive": "Aktiv",
+ "statusInactive": "Inaktiv",
+ "used": "Genutzte Events",
+ "available": "Verfügbar",
+ "expires": "Läuft ab"
+ }
+ }
+ }
+ }
}
diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json
index 3e7f2f6..d787652 100644
--- a/resources/js/admin/i18n/locales/en/dashboard.json
+++ b/resources/js/admin/i18n/locales/en/dashboard.json
@@ -7,7 +7,7 @@
"welcome": {
"fallbackName": "Tenant Admin",
"greeting": "Welcome, {{name}}!",
- "subtitle": "Keep your events, credits, and tasks on track."
+ "subtitle": "Keep your events, packages, and tasks on track."
},
"errors": {
"loadFailed": "Dashboard could not be loaded."
@@ -27,6 +27,7 @@
"description": "Key tenant metrics at a glance.",
"noPackage": "No active package",
"stats": {
+ "activePackage": "Active package",
"activeEvents": "Active events",
"publishedHint": "{{count}} published",
"newPhotos": "New photos (7 days)",
@@ -50,9 +51,9 @@
"label": "Organise tasks",
"description": "Assign clear responsibilities."
},
- "manageCredits": {
- "label": "Manage credits",
- "description": "Review balance and ledger."
+ "managePackages": {
+ "label": "Manage packages",
+ "description": "View your active package and history."
}
},
"upcoming": {
@@ -68,5 +69,78 @@
"planning": "In planning",
"noDate": "No date"
}
+ },
+ "dashboard": {
+ "actions": {
+ "newEvent": "New Event",
+ "allEvents": "All events",
+ "guidedSetup": "Guided setup"
+ },
+ "welcome": {
+ "fallbackName": "Tenant Admin",
+ "greeting": "Welcome, {{name}}!",
+ "subtitle": "Keep your events, packages, and tasks on track."
+ },
+ "errors": {
+ "loadFailed": "Dashboard could not be loaded."
+ },
+ "alerts": {
+ "errorTitle": "Error"
+ },
+ "welcomeCard": {
+ "title": "Start with the welcome journey",
+ "summary": "Discover the storytelling elements, choose your package, and create your first event with guided steps.",
+ "body1": "We guide you through packages, tasks, and gallery setup so your event shines.",
+ "body2": "You can return to the welcome journey at any time, even once events are live.",
+ "cta": "Start now"
+ },
+ "overview": {
+ "title": "At a glance",
+ "description": "Key tenant metrics at a glance.",
+ "noPackage": "No active package",
+ "stats": {
+ "activePackage": "Active package",
+ "activeEvents": "Active events",
+ "publishedHint": "{{count}} published",
+ "newPhotos": "New photos (7 days)",
+ "taskProgress": "Task progress",
+ "credits": "Credits",
+ "lowCredits": "Top up recommended"
+ }
+ },
+ "quickActions": {
+ "title": "Quick actions",
+ "description": "Jump straight to the most important actions.",
+ "createEvent": {
+ "label": "Create event",
+ "description": "Plan your next highlight."
+ },
+ "moderatePhotos": {
+ "label": "Moderate photos",
+ "description": "Review new uploads."
+ },
+ "organiseTasks": {
+ "label": "Organise tasks",
+ "description": "Assign clear responsibilities."
+ },
+ "managePackages": {
+ "label": "Manage packages",
+ "description": "View your active package and history."
+ }
+ },
+ "upcoming": {
+ "title": "Upcoming events",
+ "description": "The next dates including status and quick access.",
+ "settings": "Open settings",
+ "empty": {
+ "message": "No events scheduled yet. Create your first one!",
+ "cta": "Plan event"
+ },
+ "status": {
+ "live": "Live",
+ "planning": "In planning",
+ "noDate": "No date"
+ }
+ }
}
}
diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json
index 67957db..b7c365e 100644
--- a/resources/js/admin/i18n/locales/en/management.json
+++ b/resources/js/admin/i18n/locales/en/management.json
@@ -1,22 +1,25 @@
{
"billing": {
- "title": "Billing & credits",
- "subtitle": "Manage balances, packages, and invoicing.",
+ "title": "Packages & billing",
+ "subtitle": "Manage your purchased packages and track their durations.",
"actions": {
"refresh": "Refresh",
"exportCsv": "Export CSV"
},
"errors": {
- "load": "Unable to load billing data.",
- "more": "Unable to load more ledger entries."
+ "load": "Unable to load package data.",
+ "more": "Unable to load more entries."
},
"sections": {
"overview": {
- "title": "Credits & status",
- "description": "Your current balance and active reseller package.",
+ "title": "Package overview",
+ "description": "Your active package and the most important metrics.",
+ "empty": "No active package yet.",
+ "emptyBadge": "No active package",
"cards": {
- "balance": {
- "label": "Available credits"
+ "package": {
+ "label": "Active package",
+ "helper": "Currently assigned"
},
"used": {
"label": "Events used",
@@ -27,13 +30,13 @@
},
"expires": {
"label": "Expires",
- "helper": "Auto-renews when active"
+ "helper": "Auto-renews if enabled"
}
}
},
"packages": {
"title": "Package history",
- "description": "Overview of active and past reseller packages.",
+ "description": "Overview of active and past packages.",
"empty": "No packages purchased yet.",
"card": {
"statusActive": "Active",
@@ -42,17 +45,18 @@
"available": "Remaining",
"expires": "Expires"
}
- },
- "ledger": {
- "title": "Credit ledger",
- "description": "All credit additions and deductions.",
- "empty": "No ledger entries recorded yet.",
- "loadMore": "Load more",
- "reasons": {
- "purchase": "Credit purchase",
- "usage": "Usage",
- "manual": "Manual adjustment"
- }
+ }
+ },
+ "packages": {
+ "title": "Package history",
+ "description": "Overview of current and past packages.",
+ "empty": "No packages purchased yet.",
+ "card": {
+ "statusActive": "Active",
+ "statusInactive": "Inactive",
+ "used": "Used events",
+ "available": "Available",
+ "expires": "Expires"
}
}
},
@@ -241,4 +245,56 @@
"submit": "Save emotion"
}
}
+ ,
+ "management": {
+ "billing": {
+ "title": "Packages & billing",
+ "subtitle": "Manage your purchased packages and track their durations.",
+ "actions": {
+ "refresh": "Refresh",
+ "exportCsv": "Export CSV"
+ },
+ "errors": {
+ "load": "Unable to load package data.",
+ "more": "Unable to load more entries."
+ },
+ "sections": {
+ "overview": {
+ "title": "Package overview",
+ "description": "Your active package and the most important metrics.",
+ "empty": "No active package yet.",
+ "emptyBadge": "No active package",
+ "cards": {
+ "package": {
+ "label": "Active package",
+ "helper": "Currently assigned"
+ },
+ "used": {
+ "label": "Events used",
+ "helper": "Remaining: {{count}}"
+ },
+ "price": {
+ "label": "Price (net)"
+ },
+ "expires": {
+ "label": "Expires",
+ "helper": "Auto-renews if enabled"
+ }
+ }
+ }
+ },
+ "packages": {
+ "title": "Package history",
+ "description": "Overview of current and past packages.",
+ "empty": "No packages purchased yet.",
+ "card": {
+ "statusActive": "Active",
+ "statusInactive": "Inactive",
+ "used": "Used events",
+ "available": "Available",
+ "expires": "Expires"
+ }
+ }
+ }
+ }
}
diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx
index 6a14b36..0bedb7e 100644
--- a/resources/js/admin/main.tsx
+++ b/resources/js/admin/main.tsx
@@ -6,8 +6,12 @@ import { AuthProvider } from './auth/context';
import { router } from './router';
import '../../css/app.css';
import './i18n';
+import './dev-tools';
import { initializeTheme } from '@/hooks/use-appearance';
import { OnboardingProgressProvider } from './onboarding';
+import { DevTenantSwitcher } from './components/DevTenantSwitcher';
+
+const enableDevSwitcher = import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true';
initializeTheme();
const rootEl = document.getElementById('root')!;
@@ -27,6 +31,7 @@ createRoot(rootEl).render(
+ {enableDevSwitcher ? : null}
);
diff --git a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx
index 6dc4f39..0526d79 100644
--- a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx
+++ b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx
@@ -11,7 +11,6 @@ import {
Loader2,
} from "lucide-react";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
-import { loadStripe } from "@stripe/stripe-js";
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
import {
@@ -32,6 +31,7 @@ import {
createTenantPayPalOrder,
captureTenantPayPalOrder,
} from "../../api";
+import { getStripe } from '@/utils/stripe';
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
@@ -267,10 +267,7 @@ export default function WelcomeOrderSummaryPage() {
const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
- const stripePromise = React.useMemo(
- () => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
- [stripePublishableKey]
- );
+ const stripePromise = React.useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
diff --git a/resources/js/admin/pages/BillingPage.tsx b/resources/js/admin/pages/BillingPage.tsx
index eb00f09..c43ebdd 100644
--- a/resources/js/admin/pages/BillingPage.tsx
+++ b/resources/js/admin/pages/BillingPage.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { CreditCard, Download, Loader2, RefreshCw, Sparkles } from 'lucide-react';
+import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -9,21 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout';
-import {
- CreditLedgerEntry,
- getCreditBalance,
- getCreditLedger,
- getTenantPackagesOverview,
- PaginationMeta,
- TenantPackageSummary,
-} from '../api';
+import { getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { isAuthError } from '../auth/tokens';
-type LedgerState = {
- entries: CreditLedgerEntry[];
- meta: PaginationMeta | null;
-};
-
export default function BillingPage() {
const { t, i18n } = useTranslation(['management', 'dashboard']);
const locale = React.useMemo(
@@ -31,13 +19,10 @@ export default function BillingPage() {
[i18n.language]
);
- const [balance, setBalance] = React.useState(0);
const [packages, setPackages] = React.useState([]);
const [activePackage, setActivePackage] = React.useState(null);
- const [ledger, setLedger] = React.useState({ entries: [], meta: null });
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
- const [loadingMore, setLoadingMore] = React.useState(false);
const formatDate = React.useCallback(
(value: string | null | undefined) => {
@@ -57,111 +42,53 @@ export default function BillingPage() {
[locale]
);
- const resolveReason = React.useCallback(
- (reason: string) => {
- switch (reason) {
- case 'purchase':
- return t('management.billing.ledger.reasons.purchase', 'Credit Kauf');
- case 'usage':
- return t('management.billing.ledger.reasons.usage', 'Verbrauch');
- case 'manual':
- return t('management.billing.ledger.reasons.manual', 'Manuelle Anpassung');
- default:
- return reason;
- }
- },
- [t]
- );
-
const packageLabels = React.useMemo(
() => ({
- statusActive: t('management.billing.packages.card.statusActive', 'Aktiv'),
- statusInactive: t('management.billing.packages.card.statusInactive', 'Inaktiv'),
- used: t('management.billing.packages.card.used', 'Genutzte Events'),
- available: t('management.billing.packages.card.available', 'Verfügbar'),
- expires: t('management.billing.packages.card.expires', 'Ablauf'),
+ statusActive: t('billing.sections.packages.card.statusActive'),
+ statusInactive: t('billing.sections.packages.card.statusInactive'),
+ used: t('billing.sections.packages.card.used'),
+ available: t('billing.sections.packages.card.available'),
+ expires: t('billing.sections.packages.card.expires'),
}),
[t]
);
- React.useEffect(() => {
- void loadAll();
- }, []);
-
- async function loadAll() {
+ const loadAll = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
- const [balanceResult, packagesResult, ledgerResult] = await Promise.all([
- safeCall(() => getCreditBalance()),
- safeCall(() => getTenantPackagesOverview()),
- safeCall(() => getCreditLedger(1)),
- ]);
-
- if (balanceResult?.balance !== undefined) {
- setBalance(balanceResult.balance);
- }
-
- if (packagesResult) {
- setPackages(packagesResult.packages);
- setActivePackage(packagesResult.activePackage);
- }
-
- if (ledgerResult) {
- setLedger({ entries: ledgerResult.data, meta: ledgerResult.meta });
- } else {
- setLedger({ entries: [], meta: null });
- }
+ const packagesResult = await getTenantPackagesOverview();
+ setPackages(packagesResult.packages);
+ setActivePackage(packagesResult.activePackage);
} catch (err) {
if (!isAuthError(err)) {
- setError(t('management.billing.errors.load', 'Billing Daten konnten nicht geladen werden.'));
+ setError(t('billing.errors.load'));
}
} finally {
setLoading(false);
}
- }
+ }, [t]);
- async function loadMore() {
- if (!ledger.meta || loadingMore) {
- return;
- }
- const { current_page, last_page } = ledger.meta;
- if (current_page >= last_page) {
- return;
- }
-
- setLoadingMore(true);
- try {
- const next = await getCreditLedger(current_page + 1);
- setLedger({
- entries: [...ledger.entries, ...next.data],
- meta: next.meta,
- });
- } catch (err) {
- if (!isAuthError(err)) {
- setError(t('management.billing.errors.more', 'Weitere Ledger Eintraege konnten nicht geladen werden.'));
- }
- } finally {
- setLoadingMore(false);
- }
- }
+ React.useEffect(() => {
+ void loadAll();
+ }, [loadAll]);
const actions = (
void loadAll()} disabled={loading}>
{loading ? : }
- {t('management.billing.actions.refresh', 'Aktualisieren')}
+ {t('billing.actions.refresh')}
);
return (
{error && (
- {t('dashboard.alerts.errorTitle', 'Fehler')}
+ {t('dashboard:alerts.errorTitle')}
{error}
)}
@@ -174,43 +101,50 @@ export default function BillingPage() {
-
- {t('management.billing.sections.overview.title', 'Credits und Status')}
+
+ {t('billing.sections.overview.title')}
- {t('management.billing.sections.overview.description', 'Dein aktuelles Guthaben und das aktive Reseller Paket.')}
+ {t('billing.sections.overview.description')}
- {activePackage ? activePackage.package_name : 'Kein aktives Paket'}
+ {activePackage ? activePackage.package_name : t('billing.sections.overview.emptyBadge')}
-
-
-
-
-
+
+ {activePackage ? (
+
+
+
+
+
+
+ ) : (
+
+ )}
@@ -218,15 +152,15 @@ export default function BillingPage() {
- {t('management.billing.packages.title', 'Paket Historie')}
+ {t('billing.sections.packages.title')}
- {t('management.billing.packages.description', 'Übersicht über aktive und vergangene Reseller Pakete.')}
+ {t('billing.sections.packages.description')}
{packages.length === 0 ? (
-
+
) : (
packages.map((pkg) => (
-
-
-
-
-
- {t('management.billing.ledger.title', 'Credit Ledger')}
-
-
- {t('management.billing.ledger.description', 'Alle Zu- und Abbuchungen deines Credits-Kontos.')}
-
-
-
-
- {t('management.billing.actions.exportCsv', 'Export als CSV')}
-
-
-
- {ledger.entries.length === 0 ? (
-
- ) : (
- <>
- {ledger.entries.map((entry) => (
-
- ))}
- {ledger.meta && ledger.meta.current_page < ledger.meta.last_page && (
- void loadMore()} disabled={loadingMore}>
- {loadingMore ? : t('management.billing.ledger.loadMore', 'Mehr laden')}
-
- )}
- >
- )}
-
-
>
)}
);
}
-
-async function safeCall(callback: () => Promise): Promise {
- try {
- return await callback();
- } catch (error) {
- if (!isAuthError(error)) {
- console.warn('[Tenant Billing] optional endpoint fehlgeschlagen', error);
- }
- return null;
- }
-}
-
function InfoCard({
label,
value,
@@ -372,33 +256,6 @@ function PackageCard({
);
}
-function LedgerRow({
- entry,
- resolveReason,
- formatDate,
-}: {
- entry: CreditLedgerEntry;
- resolveReason: (reason: string) => string;
- formatDate: (value: string | null | undefined) => string;
-}) {
- const positive = entry.delta >= 0;
- return (
-
-
-
{resolveReason(entry.reason)}
- {entry.note &&
{entry.note}
}
-
-
-
- {positive ? '+' : ''}
- {entry.delta}
-
- {formatDate(entry.created_at)}
-
-
- );
-}
-
function EmptyState({ message }: { message: string }) {
return (
diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx
index f946eef..d1a0fdd 100644
--- a/resources/js/admin/pages/DashboardPage.tsx
+++ b/resources/js/admin/pages/DashboardPage.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { CalendarDays, Camera, CreditCard, Sparkles, Users, Plus, Settings } from 'lucide-react';
+import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -11,7 +11,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { AdminLayout } from '../components/AdminLayout';
import {
DashboardSummary,
- getCreditBalance,
getDashboardSummary,
getEvents,
getTenantPackagesOverview,
@@ -35,7 +34,6 @@ import { useOnboardingProgress } from '../onboarding';
interface DashboardState {
summary: DashboardSummary | null;
events: TenantEvent[];
- credits: number;
activePackage: TenantPackageSummary | null;
loading: boolean;
errorKey: string | null;
@@ -46,11 +44,23 @@ export default function DashboardPage() {
const location = useLocation();
const { user } = useAuth();
const { progress, markStep } = useOnboardingProgress();
- const { t, i18n } = useTranslation(['dashboard', 'common']);
+ const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
+ const { t: tc } = useTranslation('common');
+
+ const translate = React.useCallback(
+ (key: string, options?: Record
) => {
+ const value = t(key, options);
+ if (value === `dashboard.${key}`) {
+ const fallback = i18n.t(`dashboard:${key}`, options);
+ return fallback === `dashboard:${key}` ? value : fallback;
+ }
+ return value;
+ },
+ [t, i18n],
+ );
const [state, setState] = React.useState({
summary: null,
events: [],
- credits: 0,
activePackage: null,
loading: true,
errorKey: null,
@@ -60,10 +70,9 @@ export default function DashboardPage() {
let cancelled = false;
(async () => {
try {
- const [summary, events, credits, packages] = await Promise.all([
+ const [summary, events, packages] = await Promise.all([
getDashboardSummary().catch(() => null),
getEvents().catch(() => [] as TenantEvent[]),
- getCreditBalance().catch(() => ({ balance: 0 })),
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
]);
@@ -71,12 +80,11 @@ export default function DashboardPage() {
return;
}
- const fallbackSummary = buildSummaryFallback(events, credits.balance ?? 0, packages.activePackage);
+ const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
setState({
summary: summary ?? fallbackSummary,
events,
- credits: credits.balance ?? 0,
activePackage: packages.activePackage,
loading: false,
errorKey: null,
@@ -97,7 +105,7 @@ export default function DashboardPage() {
};
}, []);
- const { summary, events, credits, activePackage, loading, errorKey } = state;
+ const { summary, events, activePackage, loading, errorKey } = state;
React.useEffect(() => {
if (loading) {
@@ -112,10 +120,10 @@ export default function DashboardPage() {
}
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
- const greetingName = user?.name ?? t('dashboard.welcome.fallbackName');
- const greetingTitle = t('dashboard.welcome.greeting', { name: greetingName });
- const subtitle = t('dashboard.welcome.subtitle');
- const errorMessage = errorKey ? t(`dashboard.errors.${errorKey}`) : null;
+ const greetingName = user?.name ?? translate('welcome.fallbackName');
+ const greetingTitle = translate('welcome.greeting', { name: greetingName });
+ const subtitle = translate('welcome.subtitle');
+ const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null;
const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const upcomingEvents = getUpcomingEvents(events);
@@ -127,10 +135,10 @@ export default function DashboardPage() {
className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
>
- {t('dashboard.actions.newEvent')}
+ {translate('actions.newEvent')}
navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
- {t('dashboard.actions.allEvents')}
+ {translate('actions.allEvents')}
{events.length === 0 && (
navigate(ADMIN_WELCOME_BASE_PATH)}
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
>
- {t('dashboard.actions.guidedSetup')}
+ {translate('actions.guidedSetup')}
)}
>
@@ -162,24 +170,23 @@ export default function DashboardPage() {
- {t('dashboard.welcomeCard.title')}
+ {translate('welcomeCard.title')}
- Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit
- geführten Schritten.
+ {translate('welcomeCard.summary')}
-
{t('dashboard.welcomeCard.body1')}
-
{t('dashboard.welcomeCard.body2')}
+
{translate('welcomeCard.body1')}
+
{translate('welcomeCard.body2')}
navigate(ADMIN_WELCOME_BASE_PATH)}
>
- {t('dashboard.welcomeCard.cta')}
+ {translate('welcomeCard.cta')}
@@ -190,74 +197,75 @@ export default function DashboardPage() {
- {t('dashboard.overview.title')}
+ {translate('overview.title')}
- {t('dashboard.overview.description')}
+ {translate('overview.description')}
- {activePackage?.package_name ?? t('dashboard.overview.noPackage')}
+ {activePackage?.package_name ?? translate('overview.noPackage')}
-
+
}
/>
}
/>
}
/>
- }
- />
+ {activePackage ? (
+ }
+ />
+ ) : null}
- {t('dashboard.quickActions.title')}
+ {translate('quickActions.title')}
- {t('dashboard.quickActions.description')}
+ {translate('quickActions.description')}
}
- label={t('dashboard.quickActions.createEvent.label')}
- description={t('dashboard.quickActions.createEvent.description')}
+ label={translate('quickActions.createEvent.label')}
+ description={translate('quickActions.createEvent.description')}
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
/>
}
- label={t('dashboard.quickActions.moderatePhotos.label')}
- description={t('dashboard.quickActions.moderatePhotos.description')}
+ label={translate('quickActions.moderatePhotos.label')}
+ description={translate('quickActions.moderatePhotos.description')}
onClick={() => navigate(ADMIN_EVENTS_PATH)}
/>
}
- label={t('dashboard.quickActions.organiseTasks.label')}
- description={t('dashboard.quickActions.organiseTasks.description')}
+ label={translate('quickActions.organiseTasks.label')}
+ description={translate('quickActions.organiseTasks.description')}
onClick={() => navigate(ADMIN_TASKS_PATH)}
/>
}
- label={t('dashboard.quickActions.manageCredits.label')}
- description={t('dashboard.quickActions.manageCredits.description')}
+ icon={ }
+ label={translate('quickActions.managePackages.label')}
+ description={translate('quickActions.managePackages.description')}
onClick={() => navigate(ADMIN_BILLING_PATH)}
/>
@@ -266,21 +274,21 @@ export default function DashboardPage() {
- {t('dashboard.upcoming.title')}
+ {translate('upcoming.title')}
- {t('dashboard.upcoming.description')}
+ {translate('upcoming.description')}
navigate(ADMIN_SETTINGS_PATH)}>
- {t('dashboard.upcoming.settings')}
+ {translate('upcoming.settings')}
{upcomingEvents.length === 0 ? (
navigate(adminPath('/events/new'))}
/>
) : (
@@ -291,10 +299,10 @@ export default function DashboardPage() {
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
locale={dateLocale}
labels={{
- live: t('dashboard.upcoming.status.live'),
- planning: t('dashboard.upcoming.status.planning'),
- open: t('common:actions.open'),
- noDate: t('dashboard.upcoming.status.noDate'),
+ live: translate('upcoming.status.live'),
+ planning: translate('upcoming.status.planning'),
+ open: tc('actions.open'),
+ noDate: translate('upcoming.status.noDate'),
}}
/>
))
@@ -309,7 +317,6 @@ export default function DashboardPage() {
function buildSummaryFallback(
events: TenantEvent[],
- balance: number,
activePackage: TenantPackageSummary | null
): DashboardSummary {
const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published'));
@@ -319,7 +326,6 @@ function buildSummaryFallback(
active_events: activeEvents.length,
new_photos: totalPhotos,
task_progress: 0,
- credit_balance: balance,
upcoming_events: activeEvents.length,
active_package: activePackage
? {
@@ -471,10 +477,3 @@ function DashboardSkeleton() {
);
}
-function renderName(name: TenantEvent['name']): string {
- if (typeof name === 'string') {
- return name;
- }
- return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
-}
-
diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx
index c37bcef..e194c86 100644
--- a/resources/js/admin/pages/EventDetailPage.tsx
+++ b/resources/js/admin/pages/EventDetailPage.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
-import { ArrowLeft, Camera, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
+import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@@ -10,6 +10,7 @@ import { AdminLayout } from '../components/AdminLayout';
import {
createInviteLink,
EventJoinToken,
+ EventJoinTokenLayout,
EventStats as TenantEventStats,
getEvent,
getEventJoinTokens,
@@ -151,7 +152,7 @@ export default function EventDetailPage() {
}));
} catch (err) {
if (!isAuthError(err)) {
- setState((prev) => ({ ...prev, error: 'Token konnte nicht deaktiviert werden.' }));
+ setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' }));
}
} finally {
setRevokingId(null);
@@ -263,22 +264,22 @@ export default function EventDetailPage() {
- Einladungen & Drucklayouts
+ Einladungslinks & QR-Layouts
- Verwalte Join-Tokens fuer dein Event. Jede Einladung enthaelt einen eindeutigen Token, QR-Code und
- downloadbare PDF/SVG-Layouts.
+ Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches
+ Vokabular.
- Teile den generierten Link oder drucke die Layouts aus, um Gaeste sicher ins Event zu leiten. Tokens lassen
- sich jederzeit rotieren oder deaktivieren.
+ Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen
+ kannst du jederzeit erneuern oder deaktivieren.
{tokens.length > 0 && (
- Aktive Tokens: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
+ Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
{tokens.length}
)}
@@ -286,7 +287,7 @@ export default function EventDetailPage() {
{creatingToken ? : }
- Join-Token erzeugen
+ Einladung erstellen
{inviteLink && (
@@ -298,7 +299,7 @@ export default function EventDetailPage() {
{tokens.length > 0 ? (
tokens.map((token) => (
-
handleCopy(token)}
@@ -308,8 +309,8 @@ export default function EventDetailPage() {
))
) : (
- Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts
- herunterzuladen.
+ Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code
+ herunterzuladen und zu teilen.
)}
@@ -371,7 +372,7 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
);
}
-function JoinTokenRow({
+function InvitationCard({
token,
onCopy,
onRevoke,
@@ -383,121 +384,150 @@ function JoinTokenRow({
revoking: boolean;
}) {
const status = getTokenStatus(token);
- const availableLayouts = Array.isArray(token.layouts) ? token.layouts : [];
+ const layouts = Array.isArray(token.layouts) ? token.layouts : [];
+ const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`;
+ const metadata = (token.metadata ?? {}) as Record
;
+ const isAutoGenerated = Boolean(metadata.auto_generated);
+
+ const statusClassname =
+ status === 'Aktiv'
+ ? 'bg-emerald-100 text-emerald-700'
+ : status === 'Abgelaufen'
+ ? 'bg-orange-100 text-orange-700'
+ : 'bg-slate-200 text-slate-700';
return (
-
-
-
- {token.label || `Einladung #${token.id}`}
-
- {status}
-
-
-
{token.url}
-
-
- Nutzung: {token.usage_count}
- {token.usage_limit ? ` / ${token.usage_limit}` : ''}
-
- {token.expires_at && Gültig bis {formatDateTime(token.expires_at)} }
- {token.created_at && Erstellt {formatDateTime(token.created_at)} }
-
- {availableLayouts.length > 0 && (
-
-
Drucklayouts
-
- {availableLayouts.map((layout) => {
- const formatEntries = Array.isArray(layout.formats)
- ? layout.formats
- .map((format) => {
- const normalized = String(format ?? '').toLowerCase();
- const href =
- layout.download_urls?.[normalized] ??
- layout.download_urls?.[String(format ?? '')] ??
- null;
-
- return {
- format: normalized,
- label: String(format ?? '').toUpperCase(),
- href,
- };
- })
- .filter((entry) => Boolean(entry.href))
- : [];
-
- if (formatEntries.length === 0) {
- return null;
- }
-
- return (
-
-
-
{layout.name}
- {layout.subtitle &&
{layout.subtitle}
}
-
-
-
- );
- })}
-
+
+
+
+
+ {token.label?.trim() || `Einladung #${token.id}`}
+ {status}
+ {isAutoGenerated ? (
+
+ Standard
+
+ ) : null}
- )}
- {!availableLayouts.length && token.layouts_url && (
-
- Drucklayouts stehen für diesen Token bereit. Öffne den Layout-Link, um PDF- oder SVG-Versionen zu laden.
+
+
+ {token.url}
+
+
+
+ Link kopieren
+
- )}
-
-
- {token.layouts_url && (
+
+ Nutzung: {usageLabel}
+ {token.expires_at ? Gültig bis {formatDateTime(token.expires_at)} : null}
+ {token.created_at ? Erstellt am {formatDateTime(token.created_at)} : null}
+
+
+
+
+
+ {layouts.length > 0 ? (
+
+ {layouts.map((layout) => (
+
+ ))}
+
+ ) : token.layouts_url ? (
+
+ Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
+
+ ) : null}
+
+ );
+}
+
+function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) {
+ const gradient = layout.preview?.background_gradient;
+ const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
+ const gradientStyle = stops.length
+ ? {
+ backgroundImage: `linear-gradient(${gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
+ }
+ : {
+ backgroundColor: layout.preview?.background ?? '#F8FAFC',
+ };
+ const textColor = layout.preview?.text ?? '#0F172A';
+
+ const formats = Array.isArray(layout.formats) ? layout.formats : [];
+
+ return (
+
+
+
+
+
+ QR-Layout
+
+
+
{layout.name}
+ {layout.subtitle ? (
+
{layout.subtitle}
+ ) : null}
+
+
+
+
+ {layout.description ?
{layout.description}
: null}
+
+ {formats.map((format) => {
+ const key = String(format ?? '').toLowerCase();
+ const href = layout.download_urls?.[key] ?? layout.download_urls?.[String(format ?? '')] ?? null;
+ if (!href) {
+ return null;
+ }
+
+ const label = String(format ?? '').toUpperCase() || 'PDF';
+
+ return (
+
+
+
+ {label}
+
+
+ );
+ })}
+
);
@@ -547,4 +577,3 @@ function renderName(name: TenantEvent['name']): string {
}
return 'Unbenanntes Event';
}
-
diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx
index bb6297a..ed54c50 100644
--- a/resources/js/admin/pages/EventFormPage.tsx
+++ b/resources/js/admin/pages/EventFormPage.tsx
@@ -4,27 +4,50 @@ import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AdminLayout } from '../components/AdminLayout';
-import { createEvent, getEvent, updateEvent, getPackages } from '../api';
+import { createEvent, getEvent, getTenantPackagesOverview, updateEvent, getPackages, getEventTypes } from '../api';
import { isAuthError } from '../auth/tokens';
-import { ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
+import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
interface EventFormState {
name: string;
slug: string;
date: string;
+ eventTypeId: number | null;
package_id: number;
isPublished: boolean;
}
+type PackageHighlight = {
+ label: string;
+ value: string;
+};
+
+const FEATURE_LABELS: Record
= {
+ basic_uploads: 'Uploads inklusive',
+ unlimited_sharing: 'Unbegrenztes Teilen',
+ no_watermark: 'Kein Wasserzeichen',
+ custom_branding: 'Eigenes Branding',
+ custom_tasks: 'Eigene Aufgaben',
+ watermark_allowed: 'Wasserzeichen erlaubt',
+ branding_allowed: 'Branding-Optionen',
+};
+
+type EventPackageMeta = {
+ id: number;
+ name: string;
+ purchasedAt: string | null;
+ expiresAt: string | null;
+};
+
export default function EventFormPage() {
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
@@ -36,7 +59,8 @@ export default function EventFormPage() {
name: '',
slug: '',
date: '',
- package_id: 1, // Default Free package
+ eventTypeId: null,
+ package_id: 0,
isPublished: false,
});
const [autoSlug, setAutoSlug] = React.useState(true);
@@ -44,12 +68,65 @@ export default function EventFormPage() {
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
+ const [readOnlyPackageName, setReadOnlyPackageName] = React.useState(null);
+ const [eventPackageMeta, setEventPackageMeta] = React.useState(null);
const { data: packages, isLoading: packagesLoading } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
+ const { data: eventTypes, isLoading: eventTypesLoading } = useQuery({
+ queryKey: ['tenant', 'event-types'],
+ queryFn: getEventTypes,
+ });
+
+ const { data: packageOverview, isLoading: overviewLoading } = useQuery({
+ queryKey: ['tenant', 'packages', 'overview'],
+ queryFn: getTenantPackagesOverview,
+ });
+
+ const activePackage = packageOverview?.activePackage ?? null;
+
+ React.useEffect(() => {
+ if (isEdit || !activePackage?.package_id) {
+ return;
+ }
+
+ setForm((prev) => {
+ if (prev.package_id === activePackage.package_id) {
+ return prev;
+ }
+
+ return {
+ ...prev,
+ package_id: activePackage.package_id,
+ };
+ });
+
+ setReadOnlyPackageName((prev) => prev ?? activePackage.package_name);
+ }, [isEdit, activePackage]);
+
+ React.useEffect(() => {
+ if (isEdit) {
+ return;
+ }
+
+ if (!eventTypes || eventTypes.length === 0) {
+ return;
+ }
+
+ setForm((prev) => {
+ if (prev.eventTypeId) {
+ return prev;
+ }
+ return {
+ ...prev,
+ eventTypeId: eventTypes[0]!.id,
+ };
+ });
+ }, [eventTypes, isEdit]);
+
React.useEffect(() => {
let cancelled = false;
if (!isEdit || !slugParam) {
@@ -69,9 +146,20 @@ export default function EventFormPage() {
name,
slug: event.slug,
date: event.event_date ? event.event_date.slice(0, 10) : '',
+ eventTypeId: event.event_type_id ?? prev.eventTypeId,
isPublished: event.status === 'published',
+ package_id: event.package?.id ? Number(event.package.id) : prev.package_id,
}));
setOriginalSlug(event.slug);
+ setReadOnlyPackageName(event.package?.name ?? null);
+ setEventPackageMeta(event.package
+ ? {
+ id: Number(event.package.id),
+ name: event.package.name ?? (typeof event.package === 'string' ? event.package : ''),
+ purchasedAt: event.package.purchased_at ?? null,
+ expiresAt: event.package.expires_at ?? null,
+ }
+ : null);
setAutoSlug(false);
} catch (err) {
if (!isAuthError(err)) {
@@ -116,17 +204,30 @@ export default function EventFormPage() {
return;
}
+ if (!form.eventTypeId) {
+ setError('Bitte waehle einen Event-Typ aus.');
+ return;
+ }
+
setSaving(true);
setError(null);
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
+ const packageIdForSubmit = form.package_id || activePackage?.package_id || null;
+
+ const shouldIncludePackage = !isEdit
+ && packageIdForSubmit
+ && (!activePackage?.package_id || packageIdForSubmit !== activePackage.package_id);
const payload = {
name: trimmedName,
slug: trimmedSlug,
- package_id: form.package_id,
- date: form.date || undefined,
+ event_type_id: form.eventTypeId,
+ event_date: form.date || undefined,
status,
+ ...(shouldIncludePackage && packageIdForSubmit
+ ? { package_id: Number(packageIdForSubmit) }
+ : {}),
};
try {
@@ -148,6 +249,77 @@ export default function EventFormPage() {
}
}
+ const effectivePackageId = form.package_id || activePackage?.package_id || null;
+
+ const selectedPackage = React.useMemo(() => {
+ if (!packages || !packages.length) {
+ return null;
+ }
+
+ if (effectivePackageId) {
+ return packages.find((pkg) => pkg.id === effectivePackageId) ?? null;
+ }
+
+ return null;
+ }, [packages, effectivePackageId]);
+
+ React.useEffect(() => {
+ if (!readOnlyPackageName && selectedPackage?.name) {
+ setReadOnlyPackageName(selectedPackage.name);
+ }
+ }, [readOnlyPackageName, selectedPackage]);
+
+ const packageNameDisplay = readOnlyPackageName
+ ?? selectedPackage?.name
+ ?? ((overviewLoading || packagesLoading) ? 'Paket wird geladen…' : 'Kein aktives Paket gefunden');
+
+ const packagePriceLabel = selectedPackage?.price !== undefined && selectedPackage?.price !== null
+ ? formatCurrency(selectedPackage.price)
+ : null;
+
+ const packageHighlights = React.useMemo(() => {
+ const highlights: PackageHighlight[] = [];
+
+ if (selectedPackage?.max_photos) {
+ highlights.push({
+ label: 'Fotos',
+ value: `${selectedPackage.max_photos.toLocaleString('de-DE')} Bilder`,
+ });
+ }
+
+ if (selectedPackage?.max_guests) {
+ highlights.push({
+ label: 'Gäste',
+ value: `${selectedPackage.max_guests.toLocaleString('de-DE')} Personen`,
+ });
+ }
+
+ if (selectedPackage?.gallery_days) {
+ highlights.push({
+ label: 'Galerie',
+ value: `${selectedPackage.gallery_days} Tage online`,
+ });
+ }
+
+ return highlights;
+ }, [selectedPackage]);
+
+ const featureTags = React.useMemo(() => {
+ if (!selectedPackage?.features) {
+ return [];
+ }
+
+ return Object.entries(selectedPackage.features)
+ .filter(([, enabled]) => Boolean(enabled))
+ .map(([key]) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' '));
+ }, [selectedPackage]);
+
+ const packageExpiresLabel = formatDate(eventPackageMeta?.expiresAt ?? activePackage?.expires_at ?? null);
+
+ const remainingEventsLabel = typeof activePackage?.remaining_events === 'number'
+ ? `Noch ${activePackage.remaining_events} Event${activePackage.remaining_events === 1 ? '' : 's'} in deinem Paket`
+ : null;
+
const actions = (
handleSlugChange(e.target.value)}
/>
- Diese Kennung wird intern verwendet. Gaeste erhalten Zugriff ausschliesslich ueber Join-Tokens und deren
- QR-/Layout-Downloads.
+ Diese Kennung wird intern verwendet. Gaeste betreten dein Event ausschliesslich ueber ihre
+ Einladungslinks und die dazugehoerigen QR-Layouts.
@@ -219,56 +391,107 @@ export default function EventFormPage() {
/>
-
Package
+
Event-Typ
setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
- disabled={packagesLoading || !packages?.length}
+ value={form.eventTypeId ? String(form.eventTypeId) : undefined}
+ onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
+ disabled={eventTypesLoading || !eventTypes?.length}
>
-
-
+
+
- {packages?.length ? (
-
- {packages.map((pkg) => (
-
- {pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
-
- ))}
-
- ) : null}
+
+ {eventTypes?.map((eventType) => (
+
+ {eventType.icon ? `${eventType.icon} ${eventType.name}` : eventType.name}
+
+ ))}
+
- {packagesLoading ? (
-
Pakete werden geladen...
+ {!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
+
+ Keine Event-Typen verfuegbar. Bitte lege einen Typ im Adminbereich an.
+
) : null}
- {!packagesLoading && (!packages || packages.length === 0) ? (
-
Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.
- ) : null}
-
-
- Package-Details
-
-
-
- Package auswaehlen
- Waehlen Sie das Package fuer Ihr Event. Hoehere Packages bieten mehr Limits und Features.
-
-
- {packages?.map((pkg) => (
-
-
{pkg.name}
-
{pkg.price} EUR
-
- Max Fotos: {pkg.max_photos}
- Max Gaeste: {pkg.max_guests}
- Galerie: {pkg.gallery_days} Tage
- Features: {Object.keys(pkg.features).filter(k => pkg.features[k]).join(', ')}
-
-
- ))}
+
+
+
+
+
+
+ {isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'}
+
+ {packagePriceLabel ? (
+
+ {packagePriceLabel}
+
+ ) : null}
-
-
+
+
+ {packageNameDisplay}
+
+
+ Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen.
+
+
+ {packageExpiresLabel ? (
+
+ Galerie aktiv bis {packageExpiresLabel}
+
+ ) : null}
+ {remainingEventsLabel ? (
+ {remainingEventsLabel}
+ ) : null}
+
+
+ {packageHighlights.length ? (
+
+ {packageHighlights.map((highlight) => (
+
+
{highlight.label}
+
{highlight.value}
+
+ ))}
+
+ ) : null}
+ {featureTags.length ? (
+
+ {featureTags.map((feature) => (
+
+ {feature}
+
+ ))}
+
+ ) : (
+
+ {(packagesLoading || overviewLoading)
+ ? 'Paketdetails werden geladen...'
+ : 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'}
+
+ )}
+
+ navigate(ADMIN_BILLING_PATH)}
+ >
+ Abrechnung öffnen
+
+
+ Upgrade-Optionen demnächst
+
+
+
+
@@ -291,7 +514,7 @@ export default function EventFormPage() {
{saving ? (
@@ -326,6 +549,43 @@ function FormSkeleton() {
);
}
+function formatCurrency(value: number | null | undefined): string | null {
+ if (value === null || value === undefined) {
+ return null;
+ }
+
+ try {
+ return new Intl.NumberFormat('de-DE', {
+ style: 'currency',
+ currency: 'EUR',
+ maximumFractionDigits: value % 1 === 0 ? 0 : 2,
+ }).format(value);
+ } catch {
+ return `${value} €`;
+ }
+}
+
+function formatDate(value: string | null | undefined): string | null {
+ if (!value) {
+ return null;
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+
+ try {
+ return new Intl.DateTimeFormat('de-DE', {
+ day: '2-digit',
+ month: 'short',
+ year: 'numeric',
+ }).format(date);
+ } catch {
+ return date.toISOString().slice(0, 10);
+ }
+}
+
function slugify(value: string): string {
return value
.normalize('NFKD')
diff --git a/resources/js/admin/pages/TasksPage.tsx b/resources/js/admin/pages/TasksPage.tsx
index 07ed88d..9e8b482 100644
--- a/resources/js/admin/pages/TasksPage.tsx
+++ b/resources/js/admin/pages/TasksPage.tsx
@@ -350,6 +350,11 @@ function TaskDialog({
saving: boolean;
isEditing: boolean;
}) {
+ const handleFormSubmit = React.useCallback((event: React.FormEvent) => {
+ event.preventDefault();
+ onSubmit(event);
+ }, [onSubmit]);
+
return (
@@ -357,7 +362,7 @@ function TaskDialog({
{isEditing ? 'Task bearbeiten' : 'Neuen Task anlegen'}
-
+
Titel
({
vi.mock('../../api', () => ({
getDashboardSummary: vi.fn().mockResolvedValue(null),
getEvents: vi.fn().mockResolvedValue([]),
- getCreditBalance: vi.fn().mockResolvedValue({ balance: 0 }),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
}));
diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx
index 3eaaea7..38003f0 100644
--- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx
+++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useRef, useEffect, useCallback } from "react";
+import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy } from "react";
import { useTranslation } from 'react-i18next';
import { Steps } from "@/components/ui/Steps";
import { Button } from "@/components/ui/button";
@@ -7,10 +7,11 @@ import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
import type { CheckoutPackage, CheckoutStepId } from "./types";
import { PackageStep } from "./steps/PackageStep";
import { AuthStep } from "./steps/AuthStep";
-import { PaymentStep } from "./steps/PaymentStep";
import { ConfirmationStep } from "./steps/ConfirmationStep";
import { useAnalytics } from '@/hooks/useAnalytics';
+const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({ default: module.PaymentStep })));
+
interface CheckoutWizardProps {
initialPackage: CheckoutPackage;
packageOptions: CheckoutPackage[];
@@ -52,6 +53,14 @@ const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: st
detailsKey: 'checkout.confirmation_step.description'
},
];
+
+const PaymentStepFallback: React.FC = () => (
+
+);
+
const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => {
const { t } = useTranslation('marketing');
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
@@ -144,7 +153,9 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
{currentStep === "package" &&
}
{currentStep === "auth" &&
}
{currentStep === "payment" && (
-
+
}>
+
+
)}
{currentStep === "confirmation" && (
diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx
index e1fdb13..8407ace 100644
--- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx
+++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx
@@ -1,12 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
-import { loadStripe } from '@stripe/stripe-js';
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { LoaderCircle } from 'lucide-react';
import { useCheckoutWizard } from '../WizardContext';
+import { getStripe } from '@/utils/stripe';
interface PaymentStepProps {
stripePublishableKey: string;
@@ -243,10 +243,7 @@ export const PaymentStep: React.FC
= ({ stripePublishableKey,
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
const [processingProvider, setProcessingProvider] = useState(null);
- const stripePromise = useMemo(
- () => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
- [stripePublishableKey]
- );
+ const stripePromise = useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
const isReseller = selectedPackage?.type === 'reseller';
diff --git a/resources/js/types/vite-env.d.ts b/resources/js/types/vite-env.d.ts
index 11f02fe..3368cf7 100644
--- a/resources/js/types/vite-env.d.ts
+++ b/resources/js/types/vite-env.d.ts
@@ -1 +1,9 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_ENABLE_TENANT_SWITCHER?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/resources/js/utils/stripe.ts b/resources/js/utils/stripe.ts
new file mode 100644
index 0000000..87a828b
--- /dev/null
+++ b/resources/js/utils/stripe.ts
@@ -0,0 +1,21 @@
+import type { Stripe } from '@stripe/stripe-js';
+
+const stripePromiseCache = new Map>();
+
+export async function getStripe(publishableKey?: string): Promise {
+ if (!publishableKey) {
+ return null;
+ }
+
+ if (!stripePromiseCache.has(publishableKey)) {
+ const promise = import('@stripe/stripe-js').then(({ loadStripe }) => loadStripe(publishableKey));
+ stripePromiseCache.set(publishableKey, promise);
+ }
+
+ return stripePromiseCache.get(publishableKey) ?? null;
+}
+
+export function clearStripeCache(): void {
+ stripePromiseCache.clear();
+}
+
diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php
index c3daa59..44e34f9 100644
--- a/resources/lang/de/admin.php
+++ b/resources/lang/de/admin.php
@@ -75,27 +75,27 @@ return [
'table' => [
'tenant' => 'Mandant',
'join' => 'Beitreten',
- 'join_tokens_total' => 'Join-Tokens: :count',
- 'join_tokens_missing' => 'Noch keine Join-Tokens erstellt',
+ 'join_tokens_total' => 'Einladungen: :count',
+ 'join_tokens_missing' => 'Noch keine Einladungen erstellt',
],
'actions' => [
'toggle_active' => 'Aktiv umschalten',
- 'join_link_qr' => 'Beitrittslink / QR',
+ 'join_link_qr' => 'Einladungslink & QR',
'download_photos' => 'Alle Fotos herunterladen',
],
'modal' => [
- 'join_link_heading' => 'Beitrittslink der Veranstaltung',
+ 'join_link_heading' => 'Einladungslink der Veranstaltung',
],
'messages' => [
- 'join_link_copied' => 'Beitrittslink kopiert',
+ 'join_link_copied' => 'Einladungslink kopiert',
],
'join_link' => [
'event_label' => 'Veranstaltung',
- 'deprecated_notice' => 'Der direkte Zugriff über den Event-Slug :slug wurde deaktiviert. Teile die Join-Tokens unten oder öffne in der Admin-App „QR & Einladungen“, um neue Codes zu verwalten.',
+ 'deprecated_notice' => 'Der direkte Zugriff über den Event-Slug :slug wurde deaktiviert. Teile die Einladungslinks unten oder öffne in der Admin-App „QR & Einladungen“, um neue Codes zu verwalten.',
'open_admin' => 'Admin-App öffnen',
- 'link_label' => 'Beitrittslink',
+ 'link_label' => 'Einladungslink',
'copy_link' => 'Kopieren',
- 'no_tokens' => 'Noch keine Join-Tokens vorhanden. Erstelle im Admin-Bereich ein Token, um dein Event zu teilen.',
+ 'no_tokens' => 'Noch keine Einladungen vorhanden. Erstelle im Admin-Bereich eine Einladung, um dein Event zu teilen.',
'token_default' => 'Einladung #:id',
'token_usage' => 'Nutzung: :usage / :limit',
'token_active' => 'Aktiv',
diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php
index d9c336d..b896789 100644
--- a/resources/lang/en/admin.php
+++ b/resources/lang/en/admin.php
@@ -75,26 +75,26 @@ return [
'table' => [
'tenant' => 'Tenant',
'join' => 'Join',
- 'join_tokens_total' => 'Join tokens: :count',
- 'join_tokens_missing' => 'No join tokens created yet',
+ 'join_tokens_total' => 'Invitations: :count',
+ 'join_tokens_missing' => 'No invitations created yet',
],
'actions' => [
'toggle_active' => 'Toggle Active',
- 'join_link_qr' => 'Join Link / QR',
+ 'join_link_qr' => 'Invitation Link & QR',
'download_photos' => 'Download all photos',
],
'modal' => [
- 'join_link_heading' => 'Event Join Link',
+ 'join_link_heading' => 'Event Invitation Link',
],
'messages' => [
- 'join_link_copied' => 'Join link copied',
+ 'join_link_copied' => 'Invitation link copied',
],
'join_link' => [
'event_label' => 'Event',
'slug_label' => 'Slug: :slug',
- 'link_label' => 'Join Link',
+ 'link_label' => 'Invitation Link',
'copy_link' => 'Copy',
- 'no_tokens' => 'No tokens available yet. Create a token in the admin app to share your event.',
+ 'no_tokens' => 'No invitations yet. Create one in the admin app to share your event.',
'token_default' => 'Invitation #:id',
'token_usage' => 'Usage: :usage / :limit',
'token_active' => 'Active',
@@ -102,7 +102,7 @@ return [
'layouts_heading' => 'Printable layouts',
'layouts_fallback' => 'Open layout overview',
'token_expiry' => 'Expires at :date',
- 'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.',
+ 'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the invitations below or manage QR layouts in the admin app.',
'open_admin' => 'Open admin app',
],
'analytics' => [
diff --git a/routes/api.php b/routes/api.php
index cf86e9d..3556285 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,19 +1,21 @@
name('api.v1.')->group(function () {
Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
Route::get('me', [OAuthController::class, 'me'])->name('tenant.me');
+ Route::get('dashboard', DashboardController::class)->name('tenant.dashboard');
+ Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index');
Route::apiResource('events', EventController::class)
->only(['index', 'show', 'destroy'])
diff --git a/routes/web.php b/routes/web.php
index 93c10a1..a891123 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,12 +2,12 @@
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\CheckoutGoogleController;
-use App\Http\Controllers\LocaleController;
use App\Http\Controllers\LegalPageController;
+use App\Http\Controllers\LocaleController;
use App\Http\Controllers\MarketingController;
-use App\Http\Controllers\Tenant\EventPhotoArchiveController;
use App\Http\Controllers\PayPalController;
use App\Http\Controllers\PayPalWebhookController;
+use App\Http\Controllers\Tenant\EventPhotoArchiveController;
use App\Models\Package;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@@ -42,10 +42,16 @@ Route::get('/blog/{slug}', [MarketingController::class, 'blogShow'])->name('blog
Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages');
Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type');
Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
-Route::view('/event-admin/auth/callback', 'admin')->name('tenant.admin.auth.callback');
-Route::view('/event-admin/login', 'admin')->name('tenant.admin.login');
-Route::view('/event-admin/logout', 'admin')->name('tenant.admin.logout');
-Route::view('/event-admin/{view?}', 'admin')->where('view', '.*')->name('tenant.admin.app');
+Route::prefix('event-admin')->group(function () {
+ $renderAdmin = fn () => view('admin');
+
+ Route::get('/auth/callback', $renderAdmin)->name('tenant.admin.auth.callback');
+ Route::get('/login', $renderAdmin)->name('tenant.admin.login');
+ Route::get('/logout', $renderAdmin)->name('tenant.admin.logout');
+ Route::get('/{view?}', $renderAdmin)
+ ->where('view', '.*')
+ ->name('tenant.admin.app');
+});
Route::view('/event', 'guest')->name('guest.pwa.landing');
Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery');
Route::view('/e/{token}/{path?}', 'guest')
diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php
index e053ed5..aa0e622 100644
--- a/tests/Feature/EventControllerTest.php
+++ b/tests/Feature/EventControllerTest.php
@@ -2,17 +2,17 @@
namespace Tests\Feature;
-use App\Models\Tenant;
-use App\Models\Package;
use App\Models\Event;
-use App\Models\User;
-use App\Models\TenantPackage;
use App\Models\EventPackage;
-use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
+use App\Models\Package;
+use App\Models\Tenant;
+use App\Models\TenantPackage;
+use App\Models\User;
use App\Services\EventJoinTokenService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
class EventControllerTest extends TestCase
{
@@ -46,6 +46,10 @@ class EventControllerTest extends TestCase
'package_id' => $package->id,
]);
+ $this->assertDatabaseHas('event_join_tokens', [
+ 'event_id' => $event->id,
+ ]);
+
$this->assertDatabaseHas('package_purchases', [
'event_id' => $event->id,
'package_id' => $package->id,
diff --git a/tests/Feature/Tenant/DashboardSummaryTest.php b/tests/Feature/Tenant/DashboardSummaryTest.php
new file mode 100644
index 0000000..1890dac
--- /dev/null
+++ b/tests/Feature/Tenant/DashboardSummaryTest.php
@@ -0,0 +1,115 @@
+setLocale('de');
+
+ $tenant = Tenant::factory()->create([
+ 'event_credits_balance' => 5,
+ ]);
+
+ $eventType = EventType::factory()->create();
+
+ $eventWithTasks = Event::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'event_type_id' => $eventType->id,
+ 'status' => 'published',
+ 'is_active' => true,
+ 'date' => now()->addDays(3),
+ ]);
+
+ $eventWithoutTasks = Event::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'event_type_id' => $eventType->id,
+ 'status' => 'draft',
+ 'is_active' => false,
+ 'date' => now()->addDays(10),
+ ]);
+
+ $task = Task::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'is_completed' => true,
+ ]);
+
+ $eventWithTasks->tasks()->attach($task->id);
+
+ Photo::factory()->create([
+ 'event_id' => $eventWithTasks->id,
+ 'tenant_id' => $tenant->id,
+ 'created_at' => now()->subDays(2),
+ ]);
+
+ Photo::factory()->create([
+ 'event_id' => $eventWithTasks->id,
+ 'tenant_id' => $tenant->id,
+ 'created_at' => now()->subDays(8),
+ ]);
+
+ $package = Package::factory()
+ ->reseller()
+ ->create([
+ 'name' => 'Standard',
+ 'name_translations' => ['de' => 'Standard', 'en' => 'Standard'],
+ 'price' => 59,
+ ]);
+
+ $activePackage = TenantPackage::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'package_id' => $package->id,
+ 'expires_at' => now()->addMonth(),
+ 'used_events' => 1,
+ 'active' => true,
+ ]);
+
+ $controller = new DashboardController;
+ $request = Request::create('/api/v1/tenant/dashboard', 'GET');
+ $request->attributes->set('decoded_token', ['tenant_id' => $tenant->id]);
+
+ $response = $controller($request);
+
+ $this->assertSame(200, $response->getStatusCode());
+
+ $payload = $response->getData(true);
+
+ $this->assertSame(1, Arr::get($payload, 'active_events'));
+ $this->assertSame(1, Arr::get($payload, 'new_photos'));
+ $this->assertSame(50, Arr::get($payload, 'task_progress'));
+ $this->assertSame(5, Arr::get($payload, 'credit_balance'));
+ $this->assertSame(2, Arr::get($payload, 'upcoming_events'));
+
+ $activePackagePayload = Arr::get($payload, 'active_package');
+
+ $this->assertIsArray($activePackagePayload);
+ $this->assertSame('Standard', Arr::get($activePackagePayload, 'name'));
+ $this->assertSame($activePackage->remaining_events, Arr::get($activePackagePayload, 'remaining_events'));
+
+ $this->assertSame(
+ $activePackage->expires_at->toIso8601String(),
+ Arr::get($payload, 'active_package.expires_at')
+ );
+
+ $this->assertSame(
+ $tenant->event_credits_balance,
+ Arr::get($payload, 'credit_balance')
+ );
+ }
+}
diff --git a/tests/Feature/Tenant/EventListTest.php b/tests/Feature/Tenant/EventListTest.php
new file mode 100644
index 0000000..93d1c58
--- /dev/null
+++ b/tests/Feature/Tenant/EventListTest.php
@@ -0,0 +1,138 @@
+for($this->tenant)
+ ->create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Package-less Event',
+ 'slug' => 'package-less-event',
+ ]);
+
+ $response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
+
+ $response->assertOk();
+ $payload = $response->json('data');
+
+ $this->assertNotEmpty($payload, 'Expected at least one event in the response');
+
+ $matchingEvent = collect($payload)->firstWhere('id', $event->id);
+
+ $this->assertNotNull($matchingEvent, 'Created event should be present in response payload');
+ $this->assertNull($matchingEvent['package'], 'Events without package should return null package data');
+ }
+
+ public function test_index_includes_package_details_when_available(): void
+ {
+ $package = Package::factory()->create([
+ 'type' => 'endcustomer',
+ 'name' => 'Standard',
+ 'name_translations' => [
+ 'de' => 'Standard',
+ 'en' => 'Standard',
+ ],
+ 'price' => 59,
+ 'gallery_days' => 45,
+ ]);
+
+ $event = Event::factory()
+ ->for($this->tenant)
+ ->create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Packaged Event',
+ 'slug' => 'packaged-event',
+ ]);
+
+ EventPackage::create([
+ 'event_id' => $event->id,
+ 'package_id' => $package->id,
+ 'purchased_price' => $package->price,
+ 'purchased_at' => Carbon::now()->subDay(),
+ 'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30),
+ 'used_photos' => 0,
+ 'used_guests' => 0,
+ ]);
+
+ $response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
+
+ $response->assertOk();
+
+ $payload = collect($response->json('data'));
+ $matchingEvent = $payload->firstWhere('id', $event->id);
+
+ $this->assertNotNull($matchingEvent, 'Packaged event should be present in response payload');
+
+ $this->assertIsArray($matchingEvent['package']);
+ $this->assertSame($package->id, $matchingEvent['package']['id']);
+ $this->assertSame('Standard', $matchingEvent['package']['name']);
+ $this->assertSame('59.00', $matchingEvent['package']['price']);
+ }
+
+ public function test_index_scopes_events_to_authenticated_tenant(): void
+ {
+ $foreignTenant = Tenant::factory()->create();
+
+ Event::factory()->for($foreignTenant)->create([
+ 'name' => 'Foreign Event',
+ 'slug' => 'foreign-event',
+ ]);
+
+ $ownEvent = Event::factory()->for($this->tenant)->create([
+ 'name' => 'Own Event',
+ 'slug' => 'own-event',
+ ]);
+
+ $response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
+
+ $response->assertOk();
+
+ $payload = collect($response->json('data'));
+
+ $this->assertTrue($payload->pluck('id')->contains($ownEvent->id), 'Authenticated tenant should see own event.');
+ $this->assertFalse($payload->pluck('slug')->contains('foreign-event'), 'Events from other tenants must be filtered out.');
+ }
+
+ public function test_index_handles_event_package_without_package_model(): void
+ {
+ $event = Event::factory()->for($this->tenant)->create([
+ 'name' => 'Legacy Event',
+ 'slug' => 'legacy-event',
+ ]);
+
+ $package = Package::factory()->create([
+ 'type' => 'endcustomer',
+ 'price' => 49,
+ ]);
+
+ EventPackage::create([
+ 'event_id' => $event->id,
+ 'package_id' => $package->id,
+ 'purchased_price' => $package->price,
+ 'purchased_at' => Carbon::now()->subDays(5),
+ 'gallery_expires_at' => Carbon::now()->addDays(15),
+ 'used_photos' => 10,
+ 'used_guests' => 25,
+ ]);
+
+ $package->delete();
+
+ $response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
+ $response->assertOk();
+
+ $matchingEvent = collect($response->json('data'))->firstWhere('id', $event->id);
+
+ $this->assertNotNull($matchingEvent, 'Event should still be returned even if package record is missing.');
+ $this->assertNull($matchingEvent['package'], 'Package payload should be null when relation cannot be resolved.');
+ }
+}
diff --git a/tests/Feature/Tenant/EventManagementTest.php b/tests/Feature/Tenant/EventManagementTest.php
new file mode 100644
index 0000000..8e474b1
--- /dev/null
+++ b/tests/Feature/Tenant/EventManagementTest.php
@@ -0,0 +1,86 @@
+count(2)->create([
+ 'name' => [
+ 'de' => 'Feier',
+ 'en' => 'Celebration',
+ ],
+ 'icon' => 'party',
+ ]);
+
+ $response = $this->authenticatedRequest('GET', '/api/v1/tenant/event-types');
+
+ $response->assertOk();
+ $payload = $response->json('data');
+
+ $this->assertCount(2, $payload);
+ $first = collect($payload)->firstWhere('id', $types->first()->id);
+
+ $this->assertNotNull($first, 'Expected event type to be present');
+ $this->assertSame('Feier', $first['name']);
+ $this->assertArrayHasKey('slug', $first);
+ $this->assertArrayHasKey('name_translations', $first);
+ $this->assertArrayHasKey('icon', $first);
+ $this->assertArrayHasKey('settings', $first);
+ }
+
+ public function test_event_can_be_created_with_event_type_and_date(): void
+ {
+ $eventType = EventType::factory()->create([
+ 'name' => [
+ 'de' => 'Hochzeit',
+ 'en' => 'Wedding',
+ ],
+ 'icon' => 'ring',
+ ]);
+
+ $package = Package::factory()->endcustomer()->create([
+ 'price' => 79.90,
+ ]);
+
+ TenantPackage::factory()
+ ->for($this->tenant)
+ ->for($package)
+ ->create([
+ 'used_events' => 0,
+ 'active' => true,
+ ]);
+
+ $this->tenant->update([
+ 'event_credits_balance' => 1,
+ ]);
+
+ $payload = [
+ 'name' => 'Launch Event',
+ 'slug' => 'launch-event',
+ 'event_type_id' => $eventType->id,
+ 'event_date' => Carbon::now()->addDays(10)->toDateString(),
+ 'status' => 'draft',
+ ];
+
+ $response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload);
+
+ $response->assertCreated();
+
+ $response->assertJsonPath('data.slug', 'launch-event');
+ $response->assertJsonPath('data.event_type_id', $eventType->id);
+
+ $this->assertDatabaseHas(Event::class, [
+ 'slug' => 'launch-event',
+ 'event_type_id' => $eventType->id,
+ 'tenant_id' => $this->tenant->id,
+ ]);
+ }
+}
diff --git a/tests/e2e/event-admin-dashboard.test.ts b/tests/e2e/event-admin-dashboard.test.ts
new file mode 100644
index 0000000..0d382ba
--- /dev/null
+++ b/tests/e2e/event-admin-dashboard.test.ts
@@ -0,0 +1,39 @@
+import { test, expectFixture as expect } from './utils/test-fixtures';
+
+test.describe('Tenant Admin – core flows', () => {
+ test('dashboard shows key sections for seeded tenant', async ({ signInTenantAdmin, page }) => {
+ await signInTenantAdmin();
+
+ await expect(page).toHaveURL(/\/event-admin(\/welcome)?/);
+
+ if (page.url().includes('/event-admin/welcome')) {
+ await page.getByRole('button', { name: /Direkt zum Dashboard/i }).click();
+ }
+
+ await expect(page.getByRole('heading', { name: /Hallo/i })).toBeVisible();
+ await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
+ await expect(page.getByRole('button', { name: /Guided Setup/i })).toBeVisible();
+
+ await expect(page.getByRole('heading', { name: /Hallo Lumen Moments!/i })).toBeVisible();
+ await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
+ await expect(page.getByRole('button', { name: /Guided Setup/i })).toBeVisible();
+ });
+
+ test('events overview lists published and draft events', async ({ signInTenantAdmin, page }) => {
+ await signInTenantAdmin();
+ await page.goto('/event-admin/events');
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.getByRole('heading', { name: /Deine Events/i })).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
+ });
+
+ test('billing page lists the active package and history', async ({ signInTenantAdmin, page }) => {
+ await signInTenantAdmin();
+ await page.goto('/event-admin/billing');
+
+ await expect(page.getByRole('heading', { name: /Pakete & Abrechnung/i })).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole('heading', { name: /Paketübersicht/i })).toBeVisible();
+ await expect(page.getByText(/Paket-Historie/)).toBeVisible();
+ });
+});
diff --git a/tests/e2e/utils/test-fixtures.ts b/tests/e2e/utils/test-fixtures.ts
index ee27d93..d55fc0b 100644
--- a/tests/e2e/utils/test-fixtures.ts
+++ b/tests/e2e/utils/test-fixtures.ts
@@ -1,4 +1,6 @@
-import { test as base, expect, Page } from '@playwright/test';
+import 'dotenv/config';
+import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
+import { randomBytes, createHash } from 'node:crypto';
export type TenantCredentials = {
email: string;
@@ -10,8 +12,8 @@ export type TenantAdminFixtures = {
signInTenantAdmin: () => Promise;
};
-const tenantAdminEmail = process.env.E2E_TENANT_EMAIL;
-const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD;
+const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.demo';
+const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!';
export const test = base.extend({
tenantAdminCredentials: async ({}, use) => {
@@ -42,10 +44,93 @@ export const test = base.extend({
export const expectFixture = expect;
-async function performTenantSignIn(page: Page, credentials: TenantCredentials) {
- await page.goto('/event-admin/login');
- await page.fill('input[name="email"]', credentials.email);
- await page.fill('input[name="password"]', credentials.password);
- await page.click('button[type="submit"]');
- await page.waitForURL(/\/event-admin(\/welcome)?/);
+const clientId = process.env.VITE_OAUTH_CLIENT_ID ?? 'tenant-admin-app';
+const redirectUri = new URL('/event-admin/auth/callback', process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:8000').toString();
+const scopes = (process.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
+
+async function performTenantSignIn(page: Page, _credentials: TenantCredentials) {
+ const tokens = await exchangeTokens(page.request);
+
+ await page.addInitScript(({ stored }) => {
+ localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(stored));
+ }, { stored: tokens });
+
+ await page.goto('/event-admin');
+ await page.waitForLoadState('domcontentloaded');
+}
+
+type StoredTokenPayload = {
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: number;
+ scope?: string;
+};
+
+async function exchangeTokens(request: APIRequestContext): Promise {
+ const verifier = generateCodeVerifier();
+ const challenge = generateCodeChallenge(verifier);
+ const state = randomBytes(12).toString('hex');
+
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ scope: scopes,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+
+ const authResponse = await request.get(`/api/v1/oauth/authorize?${params.toString()}`, {
+ maxRedirects: 0,
+ headers: {
+ 'x-playwright-test': 'tenant-admin',
+ },
+ });
+
+ if (authResponse.status() >= 400) {
+ throw new Error(`OAuth authorize failed: ${authResponse.status()} ${await authResponse.text()}`);
+ }
+
+ const location = authResponse.headers()['location'];
+ if (!location) {
+ throw new Error('OAuth authorize did not return redirect location');
+ }
+
+ const code = new URL(location).searchParams.get('code');
+ if (!code) {
+ throw new Error('OAuth authorize response missing code');
+ }
+
+ const tokenResponse = await request.post('/api/v1/oauth/token', {
+ form: {
+ grant_type: 'authorization_code',
+ code,
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ code_verifier: verifier,
+ },
+ });
+
+ if (!tokenResponse.ok()) {
+ throw new Error(`OAuth token exchange failed: ${tokenResponse.status()} ${await tokenResponse.text()}`);
+ }
+
+ const body = await tokenResponse.json();
+ const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
+
+ return {
+ accessToken: body.access_token,
+ refreshToken: body.refresh_token,
+ expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
+ scope: body.scope,
+ };
+}
+
+function generateCodeVerifier(): string {
+ return randomBytes(32).toString('base64url');
+}
+
+function generateCodeChallenge(verifier: string): string {
+ return createHash('sha256').update(verifier).digest('base64url');
}