From 9394c3171e652fbebe340c5a8ff94780c0a1746a Mon Sep 17 00:00:00 2001
From: Codex Agent
Date: Sun, 12 Oct 2025 10:32:37 +0200
Subject: [PATCH] Added opaque join-token support across backend and frontend:
new migration/model/service/endpoints, guest controllers now resolve tokens,
and the demo seeder seeds a token. Tenant event details list/manage tokens
with copy/revoke actions, and the guest PWA uses tokens end-to-end (routing,
storage, uploads, achievements, etc.). Docs TODO updated to reflect completed
steps.
---
app/Console/Commands/MigrateToPackages.php | 2 +-
.../Controllers/Api/EventPublicController.php | 115 +++--
.../Api/Tenant/EventController.php | 28 +-
.../Api/Tenant/EventJoinTokenController.php | 73 +++
app/Http/Controllers/Api/TenantController.php | 21 +-
app/Http/Controllers/MarketingController.php | 18 +-
.../Tenant/EventJoinTokenResource.php | 29 ++
app/Models/Event.php | 5 +
app/Models/EventJoinToken.php | 58 +++
app/Services/EventJoinTokenService.php | 85 ++++
..._120000_create_event_join_tokens_table.php | 37 ++
database/seeders/DemoEventSeeder.php | 11 +-
...2025-10-10-tenant-admin-onboarding-plan.md | 2 +
docs/todo/event-join-token-hardening.md | 34 ++
docs/todo/tenant-admin-onboarding-fusion.md | 3 +-
resources/js/admin/api.ts | 67 ++-
resources/js/admin/components/AdminLayout.tsx | 23 +-
.../js/admin/components/LanguageSwitcher.tsx | 109 +++++
resources/js/admin/i18n/index.ts | 61 +++
.../js/admin/i18n/locales/de/common.json | 21 +
.../js/admin/i18n/locales/de/dashboard.json | 72 +++
.../js/admin/i18n/locales/de/management.json | 150 ++++++
.../js/admin/i18n/locales/de/onboarding.json | 268 +++++++++++
.../js/admin/i18n/locales/en/common.json | 21 +
.../js/admin/i18n/locales/en/dashboard.json | 72 +++
.../js/admin/i18n/locales/en/management.json | 149 ++++++
.../js/admin/i18n/locales/en/onboarding.json | 264 +++++++++++
resources/js/admin/main.tsx | 1 +
.../components/TenantWelcomeLayout.tsx | 9 +-
.../pages/WelcomeEventSetupPage.tsx | 84 ++--
.../onboarding/pages/WelcomeLandingPage.tsx | 62 ++-
.../pages/WelcomeOrderSummaryPage.tsx | 440 ++++++++++--------
.../onboarding/pages/WelcomePackagesPage.tsx | 340 ++++++++------
resources/js/admin/pages/BillingPage.tsx | 199 +++++---
resources/js/admin/pages/DashboardPage.tsx | 164 ++++---
resources/js/admin/pages/EventDetailPage.tsx | 181 ++++++-
resources/js/admin/pages/EventMembersPage.tsx | 153 +++---
resources/js/admin/pages/EventTasksPage.tsx | 62 ++-
resources/js/components/delete-user.tsx | 2 +-
resources/js/guest/components/BottomNav.tsx | 8 +-
.../js/guest/components/EmotionPicker.tsx | 19 +-
.../js/guest/components/GalleryPreview.tsx | 5 +-
resources/js/guest/components/Header.tsx | 4 +-
.../js/guest/context/EventStatsContext.tsx | 9 +-
.../js/guest/context/GuestIdentityContext.tsx | 36 +-
resources/js/guest/hooks/useEventData.ts | 12 +-
.../js/guest/hooks/useGuestTaskProgress.ts | 30 +-
resources/js/guest/main.tsx | 5 +-
resources/js/guest/pages/AchievementsPage.tsx | 2 +-
resources/js/guest/pages/GalleryPage.tsx | 8 +-
resources/js/guest/pages/HomePage.tsx | 8 +-
resources/js/guest/pages/LandingPage.tsx | 52 ++-
resources/js/guest/pages/LegalPage.tsx | 7 +-
resources/js/guest/pages/PhotoLightbox.tsx | 12 +-
resources/js/guest/pages/ProfileSetupPage.tsx | 12 +-
resources/js/guest/pages/TaskPickerPage.tsx | 16 +-
resources/js/guest/pages/UploadPage.tsx | 37 +-
.../js/guest/polling/usePollGalleryDelta.ts | 11 +
resources/js/guest/polling/usePollStats.ts | 16 +-
resources/js/guest/router.tsx | 24 +-
resources/js/guest/services/eventApi.ts | 19 +-
resources/js/guest/services/photosApi.ts | 3 +-
resources/js/hooks/use-appearance.ts | 4 +-
resources/js/layouts/app/Header.tsx | 268 +++++++++--
resources/js/layouts/mainWebsite.tsx | 4 +-
resources/js/pages/Profile/Orders.tsx | 5 +-
resources/js/pages/auth/RegisterForm.tsx | 26 +-
resources/js/pages/auth/forgot-password.tsx | 1 +
.../js/pages/marketing/CheckoutWizardPage.tsx | 3 +
.../marketing/checkout/CheckoutWizard.tsx | 10 +-
resources/js/pages/settings/password.tsx | 4 +-
resources/js/pages/settings/profile.tsx | 2 +-
routes/api.php | 11 +-
73 files changed, 3277 insertions(+), 911 deletions(-)
create mode 100644 app/Http/Controllers/Api/Tenant/EventJoinTokenController.php
create mode 100644 app/Http/Resources/Tenant/EventJoinTokenResource.php
create mode 100644 app/Models/EventJoinToken.php
create mode 100644 app/Services/EventJoinTokenService.php
create mode 100644 database/migrations/2025_10_11_120000_create_event_join_tokens_table.php
create mode 100644 docs/todo/event-join-token-hardening.md
create mode 100644 resources/js/admin/components/LanguageSwitcher.tsx
create mode 100644 resources/js/admin/i18n/index.ts
create mode 100644 resources/js/admin/i18n/locales/de/common.json
create mode 100644 resources/js/admin/i18n/locales/de/dashboard.json
create mode 100644 resources/js/admin/i18n/locales/de/management.json
create mode 100644 resources/js/admin/i18n/locales/de/onboarding.json
create mode 100644 resources/js/admin/i18n/locales/en/common.json
create mode 100644 resources/js/admin/i18n/locales/en/dashboard.json
create mode 100644 resources/js/admin/i18n/locales/en/management.json
create mode 100644 resources/js/admin/i18n/locales/en/onboarding.json
diff --git a/app/Console/Commands/MigrateToPackages.php b/app/Console/Commands/MigrateToPackages.php
index fa05305..a078412 100644
--- a/app/Console/Commands/MigrateToPackages.php
+++ b/app/Console/Commands/MigrateToPackages.php
@@ -73,7 +73,7 @@ class MigrateToPackages extends Command
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'package_id' => $freePackage->id,
- 'type' => 'endcustomer_event',
+ 'type' => 'endcustomer',
'provider_id' => 'migration',
'price' => 0,
'metadata' => ['migrated_from_credits' => true],
diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php
index 8d3a97f..d054c3b 100644
--- a/app/Http/Controllers/Api/EventPublicController.php
+++ b/app/Http/Controllers/Api/EventPublicController.php
@@ -10,9 +10,45 @@ use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use App\Support\ImageHelper;
+use App\Services\EventJoinTokenService;
class EventPublicController extends BaseController
{
+ public function __construct(private readonly EventJoinTokenService $joinTokenService)
+ {
+ }
+
+ /**
+ * @return array{0: object|null, 1: \App\Models\EventJoinToken|null}
+ */
+ private function resolvePublishedEvent(string $identifier, array $columns = ['id']): array
+ {
+ $event = DB::table('events')
+ ->where('slug', $identifier)
+ ->where('status', 'published')
+ ->first($columns);
+
+ if ($event) {
+ return [$event, null];
+ }
+
+ $joinToken = $this->joinTokenService->findActiveToken($identifier);
+ if (! $joinToken) {
+ return [null, null];
+ }
+
+ $event = DB::table('events')
+ ->where('id', $joinToken->event_id)
+ ->where('status', 'published')
+ ->first($columns);
+
+ if (! $event) {
+ return [null, null];
+ }
+
+ return [$event, $joinToken];
+ }
+
private function getLocalized($value, $locale, $default = '') {
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -45,36 +81,46 @@ class EventPublicController extends BaseController
return $path; // fallback as-is
}
- public function event(string $slug)
+ public function event(string $identifier)
{
- $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first([
- 'id', 'slug', 'name', 'default_locale', 'created_at', 'updated_at'
+ [$event, $joinToken] = $this->resolvePublishedEvent($identifier, [
+ 'id',
+ 'slug',
+ 'name',
+ 'default_locale',
+ 'created_at',
+ 'updated_at',
+ 'event_type_id',
]);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
- $locale = request()->query('locale', 'de');
+ $locale = request()->query('locale', $event->default_locale ?? 'de');
$nameData = json_decode($event->name, true);
$localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name;
- // Get event type for icon
- $eventType = DB::table('events')
- ->join('event_types', 'events.event_type_id', '=', 'event_types.id')
- ->where('events.id', $event->id)
- ->first(['event_types.slug as type_slug', 'event_types.name as type_name']);
+ $eventType = null;
+ if ($event->event_type_id) {
+ $eventType = DB::table('event_types')
+ ->where('id', $event->event_type_id)
+ ->first(['slug as type_slug', 'name as type_name']);
+ }
- $locale = request()->query('locale', 'de');
$eventTypeData = $eventType ? [
'slug' => $eventType->type_slug,
'name' => $this->getLocalized($eventType->type_name, $locale, 'Event'),
- 'icon' => $eventType->type_slug === 'wedding' ? '❤️' : '👥'
+ 'icon' => $eventType->type_slug === 'wedding' ? 'heart' : 'guests',
] : [
'slug' => 'general',
'name' => $this->getLocalized('Event', $locale, 'Event'),
- 'icon' => '👥'
+ 'icon' => 'guests',
];
+ if ($joinToken) {
+ $this->joinTokenService->incrementUsage($joinToken);
+ }
+
return response()->json([
'id' => $event->id,
'slug' => $event->slug,
@@ -83,12 +129,13 @@ class EventPublicController extends BaseController
'created_at' => $event->created_at,
'updated_at' => $event->updated_at,
'type' => $eventTypeData,
+ 'join_token' => $joinToken?->token,
])->header('Cache-Control', 'no-store');
}
- public function stats(string $slug)
+ public function stats(string $identifier)
{
- $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
+ [$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
@@ -125,18 +172,20 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
- public function emotions(string $slug)
+ public function emotions(string $identifier)
{
- $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
+ [$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
+ $eventId = $event->id;
+
$rows = DB::table('emotions')
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
->join('event_types', 'emotion_event_type.event_type_id', '=', 'event_types.id')
->join('events', 'events.event_type_id', '=', 'event_types.id')
- ->where('events.id', $event->id)
+ ->where('events.id', $eventId)
->select([
'emotions.id',
'emotions.name',
@@ -174,17 +223,19 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
- public function tasks(string $slug, Request $request)
+ public function tasks(string $identifier, Request $request)
{
- $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
+ [$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
+ $eventId = $event->id;
+
$query = DB::table('tasks')
->join('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id')
->join('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id')
- ->where('event_task_collection.event_id', $event->id)
+ ->where('event_task_collection.event_id', $eventId)
->select([
'tasks.id',
'tasks.title',
@@ -258,9 +309,9 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
- public function photos(Request $request, string $slug)
+ public function photos(Request $request, string $identifier)
{
- $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
+ [$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
@@ -404,18 +455,19 @@ class EventPublicController extends BaseController
return response()->json(['liked' => true, 'likes_count' => $count]);
}
- public function upload(Request $request, string $slug)
+ public function upload(Request $request, string $identifier)
{
- $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
+ [$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
+ $eventId = $event->id;
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon';
// Per-device cap per event (MVP: 50)
- $deviceCount = DB::table('photos')->where('event_id', $event->id)->where('guest_name', $deviceId)->count();
+ $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
if ($deviceCount >= 50) {
return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429);
}
@@ -429,17 +481,17 @@ class EventPublicController extends BaseController
]);
$file = $validated['photo'];
- $path = Storage::disk('public')->putFile("events/{$event->id}/photos", $file);
+ $path = Storage::disk('public')->putFile("events/{$eventId}/photos", $file);
$url = Storage::url($path);
// Generate thumbnail (JPEG) under photos/thumbs
$baseName = pathinfo($path, PATHINFO_FILENAME);
- $thumbRel = "events/{$event->id}/photos/thumbs/{$baseName}_thumb.jpg";
+ $thumbRel = "events/{$eventId}/photos/thumbs/{$baseName}_thumb.jpg";
$thumbPath = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbRel, 640, 82);
$thumbUrl = $thumbPath ? Storage::url($thumbPath) : $url;
$id = DB::table('photos')->insertGetId([
- 'event_id' => $event->id,
+ 'event_id' => $eventId,
'task_id' => $validated['task_id'] ?? null,
'guest_name' => $validated['guest_name'] ?? $deviceId,
'file_path' => $url,
@@ -447,7 +499,7 @@ class EventPublicController extends BaseController
'likes_count' => 0,
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
- 'emotion_id' => $this->resolveEmotionId($validated, $event->id),
+ 'emotion_id' => $this->resolveEmotionId($validated, $eventId),
'is_featured' => 0,
'metadata' => null,
'created_at' => now(),
@@ -502,9 +554,9 @@ class EventPublicController extends BaseController
return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
}
- public function achievements(Request $request, string $slug)
+ public function achievements(Request $request, string $identifier)
{
- $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
+ [$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
@@ -718,4 +770,3 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
}
-
diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php
index cf9574a..1c7bbf4 100644
--- a/app/Http/Controllers/Api/Tenant/EventController.php
+++ b/app/Http/Controllers/Api/Tenant/EventController.php
@@ -4,10 +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\Photo;
+use App\Services\EventJoinTokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -18,6 +20,10 @@ use Illuminate\Validation\ValidationException;
class EventController extends Controller
{
+ public function __construct(private readonly EventJoinTokenService $joinTokenService)
+ {
+ }
+
public function index(Request $request): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
@@ -283,12 +289,26 @@ class EventController extends Controller
return response()->json(['error' => 'Event not found'], 404);
}
- $token = (string) Str::uuid();
- $link = url("/e/{$event->slug}?invite={$token}");
+ $validated = $request->validate([
+ 'label' => ['nullable', 'string', 'max:255'],
+ 'expires_at' => ['nullable', 'date', 'after:now'],
+ 'usage_limit' => ['nullable', 'integer', 'min:1'],
+ ]);
+
+ $attributes = array_filter([
+ 'label' => $validated['label'] ?? null,
+ 'expires_at' => $validated['expires_at'] ?? null,
+ 'usage_limit' => $validated['usage_limit'] ?? null,
+ 'created_by' => $request->user()?->id,
+ ], fn ($value) => ! is_null($value));
+
+ $joinToken = $this->joinTokenService->createToken($event, $attributes);
return response()->json([
- 'link' => $link,
- 'token' => $token,
+ 'link' => url("/e/{$event->slug}?invite={$joinToken->token}"),
+ 'token' => $joinToken->token,
+ 'token_url' => url('/e/'.$joinToken->token),
+ 'join_token' => new EventJoinTokenResource($joinToken),
]);
}
public function bulkUpdateStatus(Request $request): JsonResponse
diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php
new file mode 100644
index 0000000..41d85ec
--- /dev/null
+++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php
@@ -0,0 +1,73 @@
+authorizeEvent($request, $event);
+
+ $tokens = $event->joinTokens()
+ ->orderByDesc('created_at')
+ ->get();
+
+ return EventJoinTokenResource::collection($tokens);
+ }
+
+ public function store(Request $request, Event $event): JsonResponse
+ {
+ $this->authorizeEvent($request, $event);
+
+ $validated = $request->validate([
+ 'label' => ['nullable', 'string', 'max:255'],
+ 'expires_at' => ['nullable', 'date', 'after:now'],
+ 'usage_limit' => ['nullable', 'integer', 'min:1'],
+ 'metadata' => ['nullable', 'array'],
+ ]);
+
+ $token = $this->joinTokenService->createToken($event, array_merge($validated, [
+ 'created_by' => Auth::id(),
+ ]));
+
+ return (new EventJoinTokenResource($token))
+ ->response()
+ ->setStatusCode(201);
+ }
+
+ public function destroy(Request $request, Event $event, EventJoinToken $joinToken): JsonResponse
+ {
+ $this->authorizeEvent($request, $event);
+
+ if ($joinToken->event_id !== $event->id) {
+ abort(404);
+ }
+
+ $reason = $request->input('reason');
+ $token = $this->joinTokenService->revoke($joinToken, $reason);
+
+ return new EventJoinTokenResource($token);
+ }
+
+ private function authorizeEvent(Request $request, Event $event): void
+ {
+ $tenantId = $request->attributes->get('tenant_id');
+
+ if ($event->tenant_id !== $tenantId) {
+ abort(404, 'Event not found');
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/TenantController.php b/app/Http/Controllers/Api/TenantController.php
index 7be8abf..ad1cc60 100644
--- a/app/Http/Controllers/Api/TenantController.php
+++ b/app/Http/Controllers/Api/TenantController.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Models\Event;
use App\Models\Photo;
use App\Models\User;
+use App\Services\EventJoinTokenService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
@@ -13,6 +14,10 @@ use Illuminate\Support\Str;
class TenantController extends BaseController
{
+ public function __construct(private readonly EventJoinTokenService $joinTokenService)
+ {
+ }
+
public function login(Request $request)
{
$creds = $request->validate([
@@ -145,7 +150,7 @@ class TenantController extends BaseController
]);
}
- public function createInvite(int $id)
+ public function createInvite(Request $request, int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
@@ -153,10 +158,16 @@ class TenantController extends BaseController
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
- $token = Str::random(32);
- Cache::put('invite:'.$token, $ev->slug, now()->addDays(2));
- $link = url('/e/'.$ev->slug).'?t='.$token;
- return response()->json(['link' => $link]);
+
+ $joinToken = $this->joinTokenService->createToken($ev, [
+ 'created_by' => $u?->id,
+ ]);
+
+ return response()->json([
+ 'link' => url('/e/'.$joinToken->token),
+ 'legacy_link' => url('/e/'.$ev->slug).'?invite='.$joinToken->token,
+ 'token' => $joinToken->token,
+ ]);
}
public function eventPhotos(int $id)
diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php
index a23d47d..a4d85e0 100644
--- a/app/Http/Controllers/MarketingController.php
+++ b/app/Http/Controllers/MarketingController.php
@@ -9,15 +9,12 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Stripe\Stripe;
-use Stripe\Checkout\Session;
use Stripe\StripeClient;
use Exception;
use PayPalHttp\Client;
use PayPalHttp\HttpException;
use PayPalCheckout\OrdersCreateRequest;
use PayPalCheckout\OrdersCaptureRequest;
-use PayPalCheckout\OrdersGetRequest;
-use PayPalCheckout\Order;
use App\Models\Tenant;
use App\Models\BlogPost;
use App\Models\Package;
@@ -71,7 +68,7 @@ class MarketingController extends Controller
*/
public function buyPackages(Request $request, $packageId)
{
- Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId]);
+ Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId, 'provider' => $request->input('provider', 'stripe')]);
$package = Package::findOrFail($packageId);
if (!Auth::check()) {
@@ -163,6 +160,8 @@ class MarketingController extends Controller
],
]);
+ Log::info('Stripe Checkout initiated', ['package_id' => $packageId, 'session_id' => $session->id, 'tenant_id' => $tenant->id]);
+
return redirect($session->url, 303);
}
@@ -212,6 +211,8 @@ class MarketingController extends Controller
$response = $ordersController->createOrder($createRequest);
$order = $response->result;
+ Log::info('PayPal Checkout initiated', ['package_id' => $packageId, 'order_id' => $order->id, 'tenant_id' => $tenant->id]);
+
session(['paypal_order_id' => $order->id]);
foreach ($order->links as $link) {
@@ -224,7 +225,7 @@ class MarketingController extends Controller
} catch (HttpException $e) {
Log::error('PayPal Orders API error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
- } catch (\Exception $e) {
+ } catch (Exception $e) {
Log::error('PayPal checkout error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
}
@@ -282,6 +283,9 @@ class MarketingController extends Controller
*/
public function success(Request $request, $packageId = null)
{
+ $provider = session('paypal_order_id') ? 'paypal' : 'stripe';
+ Log::info('Payment Success: Provider processed', ['provider' => $provider, 'package_id' => $packageId]);
+
if (session('paypal_order_id')) {
$orderId = session('paypal_order_id');
$client = Client::create([
@@ -299,6 +303,8 @@ class MarketingController extends Controller
$captureResponse = $ordersController->captureOrder($captureRequest);
$capture = $captureResponse->result;
+ Log::info('PayPal Capture completed', ['order_id' => $orderId, 'status' => $capture->status]);
+
if ($capture->status === 'COMPLETED') {
$customId = $capture->purchaseUnits[0]->customId ?? null;
if ($customId) {
@@ -423,7 +429,7 @@ class MarketingController extends Controller
// Transform to array with translated strings for the current locale
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
- $converter = new \League\CommonMark\CommonMarkConverter();
+ $converter = new CommonMarkConverter();
$contentHtml = (string) $converter->convert($markdown);
// Debug log for content_html
diff --git a/app/Http/Resources/Tenant/EventJoinTokenResource.php b/app/Http/Resources/Tenant/EventJoinTokenResource.php
new file mode 100644
index 0000000..2ee27a4
--- /dev/null
+++ b/app/Http/Resources/Tenant/EventJoinTokenResource.php
@@ -0,0 +1,29 @@
+ $this->id,
+ 'label' => $this->label,
+ 'token' => $this->token,
+ 'url' => url('/e/'.$this->token),
+ 'usage_limit' => $this->usage_limit,
+ 'usage_count' => $this->usage_count,
+ 'expires_at' => optional($this->expires_at)->toIso8601String(),
+ 'revoked_at' => optional($this->revoked_at)->toIso8601String(),
+ 'is_active' => $this->isActive(),
+ 'created_at' => optional($this->created_at)->toIso8601String(),
+ 'metadata' => $this->metadata ?? new \stdClass(),
+ ];
+ }
+}
diff --git a/app/Models/Event.php b/app/Models/Event.php
index 21093e3..2b96d62 100644
--- a/app/Models/Event.php
+++ b/app/Models/Event.php
@@ -58,6 +58,11 @@ class Event extends Model
return $this->belongsTo(EventPackage::class);
}
+ public function joinTokens(): HasMany
+ {
+ return $this->hasMany(EventJoinToken::class);
+ }
+
public function hasActivePackage(): bool
{
return $this->eventPackage && $this->eventPackage->isActive();
diff --git a/app/Models/EventJoinToken.php b/app/Models/EventJoinToken.php
new file mode 100644
index 0000000..a3029f7
--- /dev/null
+++ b/app/Models/EventJoinToken.php
@@ -0,0 +1,58 @@
+ 'array',
+ 'expires_at' => 'datetime',
+ 'revoked_at' => 'datetime',
+ 'usage_limit' => 'integer',
+ 'usage_count' => 'integer',
+ ];
+
+ public function event(): BelongsTo
+ {
+ return $this->belongsTo(Event::class);
+ }
+
+ public function creator(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'created_by');
+ }
+
+ public function isActive(): bool
+ {
+ if ($this->revoked_at !== null) {
+ return false;
+ }
+
+ if ($this->expires_at !== null && $this->expires_at->isPast()) {
+ return false;
+ }
+
+ if ($this->usage_limit !== null && $this->usage_count >= $this->usage_limit) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/app/Services/EventJoinTokenService.php b/app/Services/EventJoinTokenService.php
new file mode 100644
index 0000000..c75fd2b
--- /dev/null
+++ b/app/Services/EventJoinTokenService.php
@@ -0,0 +1,85 @@
+generateUniqueToken();
+
+ $payload = [
+ 'event_id' => $event->id,
+ 'token' => $tokenValue,
+ 'label' => Arr::get($attributes, 'label'),
+ 'usage_limit' => Arr::get($attributes, 'usage_limit'),
+ 'metadata' => Arr::get($attributes, 'metadata', []),
+ ];
+
+ if ($expiresAt = Arr::get($attributes, 'expires_at')) {
+ $payload['expires_at'] = $expiresAt instanceof Carbon
+ ? $expiresAt
+ : Carbon::parse($expiresAt);
+ }
+
+ if ($createdBy = Arr::get($attributes, 'created_by')) {
+ $payload['created_by'] = $createdBy;
+ }
+
+ return EventJoinToken::create($payload);
+ });
+ }
+
+ public function revoke(EventJoinToken $joinToken, ?string $reason = null): EventJoinToken
+ {
+ $joinToken->revoked_at = now();
+
+ if ($reason) {
+ $metadata = $joinToken->metadata ?? [];
+ $metadata['revoked_reason'] = $reason;
+ $joinToken->metadata = $metadata;
+ }
+
+ $joinToken->save();
+
+ return $joinToken;
+ }
+
+ public function incrementUsage(EventJoinToken $joinToken): void
+ {
+ $joinToken->increment('usage_count');
+ }
+
+ public function findActiveToken(string $token): ?EventJoinToken
+ {
+ return EventJoinToken::query()
+ ->where('token', $token)
+ ->whereNull('revoked_at')
+ ->where(function ($query) {
+ $query->whereNull('expires_at')
+ ->orWhere('expires_at', '>', now());
+ })
+ ->where(function ($query) {
+ $query->whereNull('usage_limit')
+ ->orWhereColumn('usage_limit', '>', 'usage_count');
+ })
+ ->first();
+ }
+
+ protected function generateUniqueToken(int $length = 48): string
+ {
+ do {
+ $token = Str::random($length);
+ } while (EventJoinToken::where('token', $token)->exists());
+
+ return $token;
+ }
+}
diff --git a/database/migrations/2025_10_11_120000_create_event_join_tokens_table.php b/database/migrations/2025_10_11_120000_create_event_join_tokens_table.php
new file mode 100644
index 0000000..71d4cee
--- /dev/null
+++ b/database/migrations/2025_10_11_120000_create_event_join_tokens_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('event_id')
+ ->constrained()
+ ->cascadeOnDelete();
+ $table->string('token', 64)->unique();
+ $table->string('label')->nullable();
+ $table->unsignedInteger('usage_limit')->nullable();
+ $table->unsignedInteger('usage_count')->default(0);
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamp('revoked_at')->nullable();
+ $table->foreignId('created_by')->nullable()
+ ->constrained('users')
+ ->nullOnDelete();
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+
+ $table->index(['event_id', 'revoked_at']);
+ $table->index(['revoked_at', 'expires_at']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('event_join_tokens');
+ }
+};
diff --git a/database/seeders/DemoEventSeeder.php b/database/seeders/DemoEventSeeder.php
index d36f36a..d6fd2a3 100644
--- a/database/seeders/DemoEventSeeder.php
+++ b/database/seeders/DemoEventSeeder.php
@@ -4,6 +4,7 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\{Event, EventType};
+use App\Services\EventJoinTokenService;
class DemoEventSeeder extends Seeder
{
@@ -13,7 +14,7 @@ class DemoEventSeeder extends Seeder
if(!$type){ return; }
$demoTenant = \App\Models\Tenant::where('slug', 'demo')->first();
if (!$demoTenant) { return; }
- Event::updateOrCreate(['slug'=>'demo-wedding-2025'], [
+ $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'],
@@ -24,5 +25,13 @@ class DemoEventSeeder extends Seeder
'settings' => json_encode([]),
'default_locale' => 'de',
]);
+
+ if ($event->joinTokens()->count() === 0) {
+ /** @var EventJoinTokenService $service */
+ $service = app(EventJoinTokenService::class);
+ $service->createToken($event, [
+ 'label' => 'Demo QR',
+ ]);
+ }
}
}
diff --git a/docs/changes/2025-10-10-tenant-admin-onboarding-plan.md b/docs/changes/2025-10-10-tenant-admin-onboarding-plan.md
index aebc7cf..0b24a7c 100644
--- a/docs/changes/2025-10-10-tenant-admin-onboarding-plan.md
+++ b/docs/changes/2025-10-10-tenant-admin-onboarding-plan.md
@@ -74,6 +74,7 @@ These primitives live under `resources/js/admin/onboarding/` and integrate with
- **Inline Checkout**: Die Order-Summary-Seite unterstützt jetzt Stripe-Kartenzahlungen (Payment Element) und PayPal (Orders API) direkt aus dem Onboarding heraus. Free-Packages lassen sich ohne Umweg aktivieren.
- Dashboard bewirbt die Welcome Journey (Actions + Hero Card) und leitet Tenants ohne Events weiterhin auf `/event-admin/welcome` um, während Fortschritt persistiert wird.
- Playwright-Skelett `tests/e2e/tenant-onboarding-flow.test.ts` angelegt und via `npm run test:e2e` ausführbar; Tests sind vorerst deaktiviert, bis Seed-Daten + Auth-Helper zur Verfügung stehen.
+- Welcome Landing, Packages, Summary und Event-Setup sind zweisprachig (DE/EN) via react-i18next; LanguageSwitcher im Dashboard & Welcome-Layout steuert die Locale.
## Status — verbleibende Arbeiten
- PayPal-Testabdeckung (Playwright/RTL) und Error-UX gehören noch in die Roadmap, ebenso wie End-to-End-Validierung auf Staging.
@@ -82,3 +83,4 @@ These primitives live under `resources/js/admin/onboarding/` and integrate with
- Keep current management modules untouched until welcome flow is ready; ship incrementally behind feature flag if needed.
- Reuse new API helpers, QueryClient, and constants to avoid divergence between flows.
+
diff --git a/docs/todo/event-join-token-hardening.md b/docs/todo/event-join-token-hardening.md
new file mode 100644
index 0000000..fd89e92
--- /dev/null
+++ b/docs/todo/event-join-token-hardening.md
@@ -0,0 +1,34 @@
+# Event Join Token Hardening TODO
+
+## Goal
+Replace slug-based guest access with opaque, revocable join tokens and provide printable QR layouts tied to those tokens.
+
+## Phase 1 – Data & Backend
+- [x] Create `event_join_tokens` table (token, event_id, usage_limit/count, expires_at, revoked_at, created_by).
+- [x] Add Eloquent model + relations (`Event::joinTokens()`), factory, and seed helper.
+- [x] Implement service for token generation/rotation (secure RNG, audit logging).
+- [x] Expose tenant API endpoints for listing/creating/revoking tokens.
+- [x] Introduce middleware/controller updates so guest API resolves `/e/{token}` → event.
+- [ ] Add rate limiting + logging for invalid token attempts.
+
+## Phase 2 – Guest PWA
+- [x] Update router and data loaders to use `:token` paths.
+- [x] Adjust storage/cache keys to use token identifiers.
+- [ ] Display friendly error states for expired/invalid tokens.
+- [ ] Regression-test photo upload, likes, and stats flows via token.
+
+## Phase 3 – Tenant Admin UX
+- [x] Build “QR & Invites” management UI (list tokens, usage stats, rotate/revoke).
+- [x] Hook Filament action + PWA screens to call new token endpoints.
+- [ ] Generate five print-ready layouts (PDF/SVG) per token with download options.
+- [ ] Deprecate slug-based QR view; link tenants to new flow.
+
+## Phase 4 – Migration & Cleanup
+- [ ] Backfill tokens for existing published events and notify tenants to reprint.
+- [ ] Remove slug parameters from public endpoints once traffic confirms token usage.
+- [ ] Update documentation (PRP, onboarding guides, runbooks) to reflect token process.
+- [ ] Add feature/integration tests covering expiry, rotation, and guest flows.
+
+## Open Questions
+- Decide on default token lifetime and rotation cadence.
+- Confirm whether guest tokens should embed locale or package metadata.
diff --git a/docs/todo/tenant-admin-onboarding-fusion.md b/docs/todo/tenant-admin-onboarding-fusion.md
index d4fca33..169b906 100644
--- a/docs/todo/tenant-admin-onboarding-fusion.md
+++ b/docs/todo/tenant-admin-onboarding-fusion.md
@@ -33,10 +33,11 @@ Owner: Codex (handoff)
- [ ] Extend docs: update PRP onboarding sections and add a walkthrough video/screencaps under docs/screenshots/tenant-admin-onboarding. Capture test scope for future Playwright/E2E coverage.
- [ ] Add automated coverage (React Testing Library for step flows, feature tests for routing guard) once implementation stabilises. Playwright spec `tests/e2e/tenant-onboarding-flow.test.ts` now executes with seeded creds—extend it to cover Stripe/PayPal happy paths and guard edge cases.
- [ ] Finalise direct checkout in the welcome summary. Stripe + PayPal hooks are live; add mocked/unit coverage and end-to-end assertions before rolling out broadly.
+- [x] Lokalisierung ausbauen: Landing-, Packages-, Summary- und Event-Setup-Screens sind nun DE/EN übersetzt; Copy-Review für weitere Module (Tasks/Billing/Members) bleibt offen.
## Risks & Open Questions
- Confirm checkout UX expectations (Stripe vs PayPal) before wiring package purchase into onboarding.
-- Validate whether onboarding flow must be localized at launch; coordinate with i18n JSON updates.
+- Validate whether onboarding flow must be localized at launch; coordinate mit den neuen i18n JSONs und fehlenden Übersetzungen.
- Determine deprecation plan for fotospiel-tenant-app/tenant-admin-app once the merged flow ships.
diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts
index ef421b0..96d3451 100644
--- a/resources/js/admin/api.ts
+++ b/resources/js/admin/api.ts
@@ -126,6 +126,20 @@ export type EventMember = {
type EventListResponse = { data?: TenantEvent[] };
type EventResponse = { data: TenantEvent };
+
+export type EventJoinToken = {
+ id: number;
+ token: string;
+ url: string;
+ label: string | null;
+ usage_limit: number | null;
+ usage_count: number;
+ expires_at: string | null;
+ revoked_at: string | null;
+ is_active: boolean;
+ created_at: string | null;
+ metadata: Record;
+};
type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto };
@@ -256,6 +270,22 @@ function normalizeMember(member: JsonValue): EventMember {
};
}
+function normalizeJoinToken(raw: JsonValue): EventJoinToken {
+ return {
+ id: Number(raw.id ?? 0),
+ token: String(raw.token ?? ''),
+ url: String(raw.url ?? ''),
+ label: raw.label ?? null,
+ usage_limit: raw.usage_limit ?? null,
+ usage_count: Number(raw.usage_count ?? 0),
+ expires_at: raw.expires_at ?? null,
+ revoked_at: raw.revoked_at ?? null,
+ is_active: Boolean(raw.is_active),
+ created_at: raw.created_at ?? null,
+ metadata: (raw.metadata ?? {}) as Record,
+ };
+}
+
function eventEndpoint(slug: string): string {
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
}
@@ -337,9 +367,40 @@ export async function getEventStats(slug: string): Promise {
};
}
-export async function createInviteLink(slug: string): Promise<{ link: string; token: string }> {
- const response = await authorizedFetch(`${eventEndpoint(slug)}/invites`, { method: 'POST' });
- return jsonOrThrow<{ link: string; token: string }>(response, 'Failed to create invite');
+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 list = Array.isArray(payload.data) ? payload.data : [];
+ return list.map(normalizeJoinToken);
+}
+
+export async function createInviteLink(
+ slug: string,
+ payload?: { label?: string; usage_limit?: number; expires_at?: string }
+): Promise {
+ const body = JSON.stringify(payload ?? {});
+ const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body,
+ });
+ const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invite');
+ return normalizeJoinToken(data.data ?? {});
+}
+
+export async function revokeEventJoinToken(
+ slug: string,
+ tokenId: number,
+ reason?: string
+): Promise {
+ const options: RequestInit = { method: 'DELETE' };
+ if (reason) {
+ options.headers = { 'Content-Type': 'application/json' };
+ 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');
+ return normalizeJoinToken(data.data ?? {});
}
export type Package = {
diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx
index fc55d09..276dac3 100644
--- a/resources/js/admin/components/AdminLayout.tsx
+++ b/resources/js/admin/components/AdminLayout.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import {
ADMIN_HOME_PATH,
@@ -8,13 +9,14 @@ import {
ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH,
} from '../constants';
+import { LanguageSwitcher } from './LanguageSwitcher';
const navItems = [
- { to: ADMIN_HOME_PATH, label: 'Dashboard', end: true },
- { to: ADMIN_EVENTS_PATH, label: 'Events' },
- { to: ADMIN_TASKS_PATH, label: 'Tasks' },
- { to: ADMIN_BILLING_PATH, label: 'Billing' },
- { to: ADMIN_SETTINGS_PATH, label: 'Einstellungen' },
+ { to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
+ { to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' },
+ { to: ADMIN_TASKS_PATH, labelKey: 'navigation.tasks' },
+ { to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' },
+ { to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' },
];
interface AdminLayoutProps {
@@ -25,6 +27,8 @@ interface AdminLayoutProps {
}
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
+ const { t } = useTranslation('common');
+
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme');
return () => {
@@ -37,11 +41,14 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
-
Fotospiel Tenant Admin
+
{t('app.brand')}
{title}
{subtitle &&
{subtitle}
}
- {actions &&
{actions}
}
+
+
+ {actions}
+
diff --git a/resources/js/admin/components/LanguageSwitcher.tsx b/resources/js/admin/components/LanguageSwitcher.tsx
new file mode 100644
index 0000000..3bf544c
--- /dev/null
+++ b/resources/js/admin/components/LanguageSwitcher.tsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import { Check, Languages } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+
+import i18n from '../i18n';
+
+type SupportedLocale = 'de' | 'en';
+
+const SUPPORTED_LANGUAGES: Array<{ code: SupportedLocale; labelKey: string }> = [
+ { code: 'de', labelKey: 'language.de' },
+ { code: 'en', labelKey: 'language.en' },
+];
+
+function getCsrfToken(): string {
+ return document.querySelector('meta[name=\"csrf-token\"]')?.content ?? '';
+}
+
+async function persistLocale(locale: SupportedLocale): Promise {
+ const response = await fetch('/set-locale', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': getCsrfToken(),
+ Accept: 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ body: JSON.stringify({ locale }),
+ credentials: 'include',
+ });
+
+ if (!response.ok) {
+ throw new Error(`locale update failed with status ${response.status}`);
+ }
+}
+
+export function LanguageSwitcher() {
+ const { t } = useTranslation('common');
+ const [pendingLocale, setPendingLocale] = React.useState(null);
+
+ const currentLocale = (i18n.language || document.documentElement.lang || 'de') as SupportedLocale;
+
+ const changeLanguage = React.useCallback(
+ async (locale: SupportedLocale) => {
+ if (locale === currentLocale || pendingLocale) {
+ return;
+ }
+
+ setPendingLocale(locale);
+ try {
+ await persistLocale(locale);
+ await i18n.changeLanguage(locale);
+ document.documentElement.setAttribute('lang', locale);
+ } catch (error) {
+ if (import.meta.env.DEV) {
+ // eslint-disable-next-line no-console
+ console.error('Failed to switch language', error);
+ }
+ } finally {
+ setPendingLocale(null);
+ }
+ },
+ [currentLocale, pendingLocale]
+ );
+
+ return (
+
+
+
+
+
+ {SUPPORTED_LANGUAGES.map(({ code, labelKey }) => {
+ const isActive = currentLocale === code;
+ const isPending = pendingLocale === code;
+
+ return (
+ {
+ event.preventDefault();
+ changeLanguage(code);
+ }}
+ className="flex items-center justify-between gap-3"
+ disabled={isPending}
+ >
+ {t(labelKey)}
+ {(isActive || isPending) && }
+
+ );
+ })}
+
+
+ );
+}
diff --git a/resources/js/admin/i18n/index.ts b/resources/js/admin/i18n/index.ts
new file mode 100644
index 0000000..8065ef6
--- /dev/null
+++ b/resources/js/admin/i18n/index.ts
@@ -0,0 +1,61 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+
+import deCommon from './locales/de/common.json';
+import enCommon from './locales/en/common.json';
+import deDashboard from './locales/de/dashboard.json';
+import enDashboard from './locales/en/dashboard.json';
+import deOnboarding from './locales/de/onboarding.json';
+import enOnboarding from './locales/en/onboarding.json';
+import deManagement from './locales/de/management.json';
+import enManagement from './locales/en/management.json';
+
+const DEFAULT_NAMESPACE = 'common';
+
+const resources = {
+ de: {
+ common: deCommon,
+ dashboard: deDashboard,
+ onboarding: deOnboarding,
+ management: deManagement,
+ },
+ en: {
+ common: enCommon,
+ dashboard: enDashboard,
+ onboarding: enOnboarding,
+ management: enManagement,
+ },
+} as const;
+
+const FALLBACK_LOCALE = 'de';
+
+if (!i18n.isInitialized) {
+ i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ fallbackLng: FALLBACK_LOCALE,
+ lng: document.documentElement.lang || undefined,
+ supportedLngs: ['de', 'en'],
+ defaultNS: DEFAULT_NAMESPACE,
+ interpolation: {
+ escapeValue: false,
+ },
+ detection: {
+ order: ['htmlTag', 'localStorage', 'cookie', 'navigator'],
+ caches: ['localStorage'],
+ htmlTag: document.documentElement,
+ },
+ returnEmptyString: false,
+ })
+ .catch((error) => {
+ if (import.meta.env.DEV) {
+ // eslint-disable-next-line no-console
+ console.error('Failed to initialize i18n', error);
+ }
+ });
+}
+
+export default i18n;
diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json
new file mode 100644
index 0000000..f80a067
--- /dev/null
+++ b/resources/js/admin/i18n/locales/de/common.json
@@ -0,0 +1,21 @@
+{
+ "app": {
+ "brand": "Fotospiel Tenant Admin",
+ "languageSwitch": "Sprache"
+ },
+ "navigation": {
+ "dashboard": "Dashboard",
+ "events": "Events",
+ "tasks": "Aufgaben",
+ "billing": "Abrechnung",
+ "settings": "Einstellungen"
+ },
+ "language": {
+ "de": "Deutsch",
+ "en": "Englisch"
+ },
+ "actions": {
+ "open": "Öffnen",
+ "viewAll": "Alle anzeigen"
+ }
+}
diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json
new file mode 100644
index 0000000..637c3e7
--- /dev/null
+++ b/resources/js/admin/i18n/locales/de/dashboard.json
@@ -0,0 +1,72 @@
+{
+ "actions": {
+ "newEvent": "Neues Event",
+ "allEvents": "Alle Events",
+ "guidedSetup": "Guided Setup"
+ },
+ "welcome": {
+ "fallbackName": "Tenant-Admin",
+ "greeting": "Hallo {{name}}!",
+ "subtitle": "Behalte deine Events, Credits 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": {
+ "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."
+ },
+ "manageCredits": {
+ "label": "Credits verwalten",
+ "description": "Sieh dir Balance & Ledger an."
+ }
+ },
+ "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
new file mode 100644
index 0000000..4a73a33
--- /dev/null
+++ b/resources/js/admin/i18n/locales/de/management.json
@@ -0,0 +1,150 @@
+{
+ "billing": {
+ "title": "Billing und Credits",
+ "subtitle": "Verwalte Guthaben, Pakete und Abrechnungen.",
+ "actions": {
+ "refresh": "Aktualisieren",
+ "exportCsv": "Export als CSV"
+ },
+ "errors": {
+ "load": "Billing-Daten konnten nicht geladen werden.",
+ "more": "Weitere Ledger-Einträge konnten nicht geladen werden."
+ },
+ "sections": {
+ "overview": {
+ "title": "Credits und Status",
+ "description": "Dein aktuelles Guthaben und das aktive Reseller-Paket.",
+ "cards": {
+ "balance": {
+ "label": "Verfügbare Credits"
+ },
+ "used": {
+ "label": "Genutzte Events",
+ "helper": "Verfügbar: {{count}}"
+ },
+ "price": {
+ "label": "Preis (netto)"
+ },
+ "expires": {
+ "label": "Ablauf",
+ "helper": "Automatisch verlängern, falls aktiv"
+ }
+ }
+ },
+ "packages": {
+ "title": "Paket-Historie",
+ "description": "Übersicht über aktive und vergangene Reseller-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"
+ }
+ }
+ }
+ },
+ "members": {
+ "title": "Event-Mitglieder",
+ "subtitle": "Verwalte Moderatoren, Admins und Helfer für dieses Event.",
+ "actions": {
+ "back": "Zurück zur Übersicht"
+ },
+ "errors": {
+ "missingSlug": "Kein Event-Slug angegeben.",
+ "load": "Mitglieder konnten nicht geladen werden.",
+ "emailRequired": "Bitte gib eine E-Mail-Adresse ein.",
+ "invite": "Einladung konnte nicht verschickt werden.",
+ "remove": "Mitglied konnte nicht entfernt werden."
+ },
+ "alerts": {
+ "notFoundTitle": "Event nicht gefunden",
+ "notFoundDescription": "Bitte kehre zur Eventliste zurück.",
+ "lockedTitle": "Feature noch nicht aktiviert",
+ "lockedDescription": "Die Mitgliederverwaltung ist für dieses Event noch nicht verfügbar. Bitte kontaktiere den Support, um das Feature freizuschalten."
+ },
+ "sections": {
+ "list": {
+ "title": "Mitglieder",
+ "empty": "Noch keine Mitglieder eingeladen."
+ },
+ "invite": {
+ "title": "Neues Mitglied einladen"
+ }
+ },
+ "labels": {
+ "status": "Status: {{status}}",
+ "joined": "Beigetreten: {{date}}"
+ },
+ "form": {
+ "emailLabel": "E-Mail",
+ "emailPlaceholder": "person@example.com",
+ "nameLabel": "Name (optional)",
+ "namePlaceholder": "Name",
+ "roleLabel": "Rolle",
+ "rolePlaceholder": "Rolle wählen",
+ "submit": "Einladung senden"
+ },
+ "roles": {
+ "tenantAdmin": "Tenant-Admin",
+ "member": "Mitglied",
+ "guest": "Gast"
+ },
+ "statuses": {
+ "published": "Veröffentlicht",
+ "draft": "Entwurf",
+ "active": "Aktiv"
+ },
+ "eventStatus": "Status: {{status}}",
+ "events": {
+ "untitled": "Unbenanntes Event"
+ }
+ },
+ "tasks": {
+ "title": "Event-Tasks",
+ "subtitle": "Verwalte Aufgaben, die diesem Event zugeordnet sind.",
+ "actions": {
+ "back": "Zurück zur Übersicht",
+ "assign": "Ausgewählte Tasks zuweisen"
+ },
+ "errors": {
+ "missingSlug": "Kein Event-Slug angegeben.",
+ "load": "Event-Tasks konnten nicht geladen werden.",
+ "assign": "Tasks konnten nicht zugewiesen werden."
+ },
+ "alerts": {
+ "notFoundTitle": "Event nicht gefunden",
+ "notFoundDescription": "Bitte kehre zur Eventliste zurück."
+ },
+ "eventStatus": "Status: {{status}}",
+ "sections": {
+ "assigned": {
+ "title": "Zugeordnete Tasks",
+ "empty": "Noch keine Tasks zugewiesen."
+ },
+ "library": {
+ "title": "Tasks aus Bibliothek hinzufügen",
+ "empty": "Keine Tasks in der Bibliothek gefunden."
+ }
+ },
+ "priorities": {
+ "low": "Niedrig",
+ "medium": "Mittel",
+ "high": "Hoch",
+ "urgent": "Dringend"
+ }
+ }
+}
+
diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json
new file mode 100644
index 0000000..0a906ff
--- /dev/null
+++ b/resources/js/admin/i18n/locales/de/onboarding.json
@@ -0,0 +1,268 @@
+{
+ "layout": {
+ "eyebrow": "Fotospiel Tenant Admin",
+ "title": "Willkommen im Event-Erlebnisstudio",
+ "subtitle": "Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie – alles optimiert für mobile Hosts.",
+ "alreadyFamiliar": "Schon vertraut mit Fotospiel?",
+ "jumpToDashboard": "Direkt zum Dashboard"
+ },
+ "hero": {
+ "eyebrow": "Dein Event, deine Bühne",
+ "title": "Gestalte das nächste Fotospiel Erlebnis",
+ "scriptTitle": "Einmalig für Gäste, mühelos für dich.",
+ "description": "Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer – inklusive Storytelling, Aufgaben und moderierter Galerie.",
+ "primary": {
+ "label": "Pakete entdecken",
+ "button": "Pakete entdecken"
+ },
+ "secondary": {
+ "label": "Events anzeigen",
+ "button": "Bestehende Events anzeigen"
+ }
+ },
+ "highlights": {
+ "gallery": {
+ "title": "Premium Gästegalerie",
+ "description": "Kuratiere Fotos in Echtzeit, markiere Highlights und teile QR-Codes mit einem Tap.",
+ "badge": "Neu"
+ },
+ "team": {
+ "title": "Flexibles Team-Onboarding",
+ "description": "Lade Co-Hosts ein, weise Rollen zu und behalte den Überblick über Moderation und Aufgaben."
+ },
+ "story": {
+ "title": "Storytelling in Etappen",
+ "description": "Geführte Aufgaben und Emotionskarten machen jedes Event zu einer erinnerungswürdigen Reise."
+ }
+ },
+ "ctaList": {
+ "choosePackage": {
+ "label": "Dein Eventpaket auswählen",
+ "description": "Reserviere Credits oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
+ "button": "Weiter zu Paketen"
+ },
+ "createEvent": {
+ "label": "Event vorbereiten",
+ "description": "Sammle Eventdetails, plane Aufgaben und sorge für einen reibungslosen Ablauf noch vor dem Tag des Events.",
+ "button": "Zum Event-Manager"
+ }
+ },
+ "packages": {
+ "layout": {
+ "eyebrow": "Schritt 2",
+ "title": "Wähle dein Eventpaket",
+ "subtitle": "Fotospiel bietet flexible Preismodelle: einmalige Credits oder Abos, die mehrere Events abdecken."
+ },
+ "step": {
+ "title": "Aktiviere die passenden Credits",
+ "description": "Sichere dir Kapazität für dein nächstes Event. Du kannst jederzeit upgraden – bezahle nur, was du brauchst."
+ },
+ "state": {
+ "loading": "Pakete werden geladen …",
+ "errorTitle": "Fehler beim Laden",
+ "errorDescription": "Bitte versuche es erneut oder kontaktiere den Support.",
+ "emptyTitle": "Der Katalog ist leer",
+ "emptyDescription": "Aktuell sind keine Pakete verfügbar. Wende dich an den Support, um neue Angebote freizuschalten."
+ },
+ "card": {
+ "subscription": "Abo",
+ "creditPack": "Credit-Paket",
+ "description": "Sofort einsatzbereit für dein nächstes Event.",
+ "descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive – perfekt für lebendige Reportagen.",
+ "active": "Aktives Paket",
+ "select": "Paket wählen",
+ "onRequest": "Auf Anfrage",
+ "purchased": "Bereits gekauft am {{date}}",
+ "purchasedUnknown": "unbekanntem Datum",
+ "badges": {
+ "guests": "{{count}} Gäste",
+ "days": "{{count}} Tage Galerie",
+ "photos": "{{count}} Fotos"
+ }
+ },
+ "features": {
+ "subscription": "Abo-Verlängerung",
+ "priority_support": "Priorisierter Support",
+ "custom_domain": "Eigene Domain",
+ "analytics": "Analytics",
+ "team_management": "Teamverwaltung",
+ "moderation_tools": "Moderationstools",
+ "prints": "Print-Uploads"
+ },
+ "cta": {
+ "billing": {
+ "label": "Direkt zum Billing",
+ "description": "Falls du schon weißt, welches Paket du brauchst, gelangst du hier zum bekannten Abrechnungsbereich.",
+ "button": "Billing öffnen"
+ },
+ "summary": {
+ "label": "Bestellübersicht anzeigen",
+ "description": "Prüfe Paketdetails und entscheide, ob du direkt zahlen oder später fortfahren möchtest.",
+ "button": "Weiter zur Übersicht"
+ }
+ }
+ },
+ "summary": {
+ "layout": {
+ "eyebrow": "Schritt 3",
+ "title": "Bestellübersicht",
+ "subtitle": "Prüfe Paket, Preis und Abrechnung bevor du zum Event-Setup wechselst."
+ },
+ "footer": {
+ "back": "Zurück zur Paketauswahl"
+ },
+ "step": {
+ "title": "Deine Auswahl im Überblick",
+ "description": "Du kannst sofort abrechnen oder das Setup fortsetzen und später bezahlen."
+ },
+ "state": {
+ "loading": "Wir prüfen verfügbare Pakete …",
+ "errorTitle": "Paketdaten derzeit nicht verfügbar",
+ "errorDescription": "Bitte versuche es erneut oder kontaktiere den Support.",
+ "missingTitle": "Keine Paketauswahl gefunden",
+ "missingDescription": "Bitte wähle zuerst ein Paket aus oder aktualisiere die Seite, falls sich Daten geändert haben."
+ },
+ "details": {
+ "subscription": "Abo",
+ "creditPack": "Credit-Paket",
+ "photos": "Bis zu {{count}} Fotos",
+ "galleryDays": "Galerie {{count}} Tage",
+ "guests": "{{count}} Gäste",
+ "infinity": "∞",
+ "features": {
+ "subscription": "Abo",
+ "priority_support": "Priorisierter Support",
+ "custom_domain": "Eigene Domain",
+ "analytics": "Analytics",
+ "team_management": "Teamverwaltung",
+ "moderation_tools": "Moderationstools",
+ "prints": "Print-Uploads"
+ },
+ "section": {
+ "photosTitle": "Fotos & Galerie",
+ "photosValue": "Bis zu {{count}} Fotos, Galerie {{days}} Tage",
+ "photosUnlimited": "Unbegrenzte Fotos, flexible Galerie",
+ "guestsTitle": "Gäste & Team",
+ "guestsValue": "{{count}} Gäste inklusive, Co-Hosts frei planbar",
+ "guestsUnlimited": "Unbegrenzte Gästeliste",
+ "featuresTitle": "Highlights",
+ "featuresNone": "Standard",
+ "statusTitle": "Status",
+ "statusActive": "Bereits gebucht",
+ "statusInactive": "Noch nicht gebucht"
+ }
+ },
+ "status": {
+ "pendingTitle": "Abrechnung steht noch aus",
+ "pendingDescription": "Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket."
+ },
+ "free": {
+ "description": "Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup weitermachen.",
+ "activate": "Gratis-Paket aktivieren",
+ "progress": "Aktivierung läuft …",
+ "successTitle": "Gratis-Paket aktiviert",
+ "successDescription": "Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.",
+ "failureTitle": "Aktivierung fehlgeschlagen",
+ "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden.",
+ },
+ "stripe": {
+ "sectionTitle": "Kartenzahlung (Stripe)",
+ "heading": "Kartenzahlung",
+ "notReady": "Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.",
+ "genericError": "Zahlung fehlgeschlagen. Bitte erneut versuchen.",
+ "missingPaymentId": "Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).",
+ "completionFailed": "Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.",
+ "errorTitle": "Zahlung fehlgeschlagen",
+ "submitting": "Zahlung wird bestätigt …",
+ "submit": "Jetzt bezahlen",
+ "hint": "Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.",
+ "loading": "Zahlungsdetails werden geladen …",
+ "unavailableTitle": "Stripe nicht verfügbar",
+ "unavailableDescription": "Stripe konnte nicht initialisiert werden.",
+ "missingKey": "Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.",
+ "intentFailed": "Stripe-Zahlung konnte nicht vorbereitet werden."
+ },
+ "paypal": {
+ "sectionTitle": "PayPal",
+ "heading": "PayPal",
+ "createFailed": "PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.",
+ "captureFailed": "PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.",
+ "errorTitle": "PayPal-Fehler",
+ "genericError": "PayPal hat ein Problem gemeldet. Bitte versuche es später erneut.",
+ "missingOrderId": "PayPal hat keine Order-ID geliefert.",
+ "cancelled": "PayPal-Zahlung wurde abgebrochen.",
+ "hint": "PayPal leitet dich ggf. weiter, um die Zahlung zu bestätigen. Anschließend kommst du automatisch zurück.",
+ "notConfiguredTitle": "PayPal nicht konfiguriert",
+ "notConfiguredDescription": "Hinterlege VITE_PAYPAL_CLIENT_ID, damit Gastgeber optional mit PayPal bezahlen können."
+ },
+ "nextStepsTitle": "Nächste Schritte",
+ "nextSteps": [
+ "Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.",
+ "Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.",
+ "Vor dem Go-Live Credits prüfen und Gäste-Link teilen."
+ ],
+ "cta": {
+ "billing": {
+ "label": "Abrechnung starten",
+ "description": "Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).",
+ "button": "Zu Billing & Zahlung"
+ },
+ "setup": {
+ "label": "Mit Event-Setup fortfahren",
+ "description": "Du kannst später jederzeit zur Abrechnung zurückkehren.",
+ "button": "Weiter zum Setup"
+ }
+ }
+ },
+ "eventSetup": {
+ "layout": {
+ "eyebrow": "Schritt 4",
+ "title": "Bereite dein erstes Event vor",
+ "subtitle": "Fülle wenige Details aus, lade Co-Hosts ein und öffne deine Gästegalerie für das große Ereignis."
+ },
+ "step": {
+ "title": "Event-Setup in Minuten",
+ "description": "Wir führen dich durch Name, Datum, Mood und Aufgaben. Danach kannst du Fotos moderieren und Gäste live begleiten."
+ },
+ "tiles": {
+ "story": {
+ "title": "Story & Stimmung",
+ "copy": "Wähle Bildsprache, Farben und Emotionskarten für dein Event."
+ },
+ "team": {
+ "title": "Team organisieren",
+ "copy": "Lade Moderator*innen oder Fotograf*innen ein und teile Rollen zu."
+ },
+ "launch": {
+ "title": "Go-Live vorbereiten",
+ "copy": "Erstelle QR-Codes, teste die Gästegalerie und kommuniziere den Ablauf."
+ }
+ },
+ "cta": {
+ "heading": "Bereit für dein erstes Event?",
+ "description": "Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die Gästegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurückkehren.",
+ "button": "Event erstellen"
+ },
+ "actions": {
+ "back": {
+ "label": "Noch einmal Pakete prüfen",
+ "description": "Vergleiche Preise oder aktualisiere dein derzeitiges Paket.",
+ "button": "Zu Paketen"
+ },
+ "dashboard": {
+ "label": "Zum Dashboard",
+ "description": "Springe ins Management, um bestehende Events zu bearbeiten.",
+ "button": "Dashboard öffnen"
+ },
+ "events": {
+ "label": "Eventübersicht",
+ "description": "Behalte den Überblick über aktive und archivierte Events.",
+ "button": "Eventliste"
+ }
+ }
+ }
+}
+
+
+
+
diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json
new file mode 100644
index 0000000..7ac658a
--- /dev/null
+++ b/resources/js/admin/i18n/locales/en/common.json
@@ -0,0 +1,21 @@
+{
+ "app": {
+ "brand": "Fotospiel Tenant Admin",
+ "languageSwitch": "Language"
+ },
+ "navigation": {
+ "dashboard": "Dashboard",
+ "events": "Events",
+ "tasks": "Tasks",
+ "billing": "Billing",
+ "settings": "Settings"
+ },
+ "language": {
+ "de": "German",
+ "en": "English"
+ },
+ "actions": {
+ "open": "Open",
+ "viewAll": "View all"
+ }
+}
diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json
new file mode 100644
index 0000000..3e7f2f6
--- /dev/null
+++ b/resources/js/admin/i18n/locales/en/dashboard.json
@@ -0,0 +1,72 @@
+{
+ "actions": {
+ "newEvent": "New Event",
+ "allEvents": "All events",
+ "guidedSetup": "Guided setup"
+ },
+ "welcome": {
+ "fallbackName": "Tenant Admin",
+ "greeting": "Welcome, {{name}}!",
+ "subtitle": "Keep your events, credits, 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": {
+ "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."
+ },
+ "manageCredits": {
+ "label": "Manage credits",
+ "description": "Review balance and ledger."
+ }
+ },
+ "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
new file mode 100644
index 0000000..dfa6e0a
--- /dev/null
+++ b/resources/js/admin/i18n/locales/en/management.json
@@ -0,0 +1,149 @@
+{
+ "billing": {
+ "title": "Billing & credits",
+ "subtitle": "Manage balances, packages, and invoicing.",
+ "actions": {
+ "refresh": "Refresh",
+ "exportCsv": "Export CSV"
+ },
+ "errors": {
+ "load": "Unable to load billing data.",
+ "more": "Unable to load more ledger entries."
+ },
+ "sections": {
+ "overview": {
+ "title": "Credits & status",
+ "description": "Your current balance and active reseller package.",
+ "cards": {
+ "balance": {
+ "label": "Available credits"
+ },
+ "used": {
+ "label": "Events used",
+ "helper": "Remaining: {{count}}"
+ },
+ "price": {
+ "label": "Price (net)"
+ },
+ "expires": {
+ "label": "Expires",
+ "helper": "Auto-renews when active"
+ }
+ }
+ },
+ "packages": {
+ "title": "Package history",
+ "description": "Overview of active and past reseller packages.",
+ "empty": "No packages purchased yet.",
+ "card": {
+ "statusActive": "Active",
+ "statusInactive": "Inactive",
+ "used": "Events used",
+ "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"
+ }
+ }
+ }
+ },
+ "members": {
+ "title": "Event members",
+ "subtitle": "Manage moderators, admins, and helpers for this event.",
+ "actions": {
+ "back": "Back to overview"
+ },
+ "errors": {
+ "missingSlug": "No event slug provided.",
+ "load": "Could not load members.",
+ "emailRequired": "Please provide an email address.",
+ "invite": "Invitation could not be sent.",
+ "remove": "Member could not be removed."
+ },
+ "alerts": {
+ "notFoundTitle": "Event not found",
+ "notFoundDescription": "Please return to the event list.",
+ "lockedTitle": "Feature not enabled",
+ "lockedDescription": "Member management isn’t available for this event yet. Contact support to activate the feature."
+ },
+ "sections": {
+ "list": {
+ "title": "Members",
+ "empty": "No members invited yet."
+ },
+ "invite": {
+ "title": "Invite new member"
+ }
+ },
+ "labels": {
+ "status": "Status: {{status}}",
+ "joined": "Joined: {{date}}"
+ },
+ "form": {
+ "emailLabel": "Email",
+ "emailPlaceholder": "person@example.com",
+ "nameLabel": "Name (optional)",
+ "namePlaceholder": "Name",
+ "roleLabel": "Role",
+ "rolePlaceholder": "Select role",
+ "submit": "Send invitation"
+ },
+ "roles": {
+ "tenantAdmin": "Tenant admin",
+ "member": "Member",
+ "guest": "Guest"
+ },
+ "statuses": {
+ "published": "Published",
+ "draft": "Draft",
+ "active": "Active"
+ },
+ "eventStatus": "Status: {{status}}",
+ "events": {
+ "untitled": "Untitled event"
+ }
+ },
+ "tasks": {
+ "title": "Event tasks",
+ "subtitle": "Manage tasks associated with this event.",
+ "actions": {
+ "back": "Back to overview",
+ "assign": "Assign selected tasks"
+ },
+ "errors": {
+ "missingSlug": "No event slug provided.",
+ "load": "Event tasks could not be loaded.",
+ "assign": "Tasks could not be assigned."
+ },
+ "alerts": {
+ "notFoundTitle": "Event not found",
+ "notFoundDescription": "Please return to the event list."
+ },
+ "eventStatus": "Status: {{status}}",
+ "sections": {
+ "assigned": {
+ "title": "Assigned tasks",
+ "empty": "No tasks assigned yet."
+ },
+ "library": {
+ "title": "Add tasks from library",
+ "empty": "No tasks found in the library."
+ }
+ },
+ "priorities": {
+ "low": "Low",
+ "medium": "Medium",
+ "high": "High",
+ "urgent": "Urgent"
+ }
+ }
+}
diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json
new file mode 100644
index 0000000..4442824
--- /dev/null
+++ b/resources/js/admin/i18n/locales/en/onboarding.json
@@ -0,0 +1,264 @@
+{
+ "layout": {
+ "eyebrow": "Fotospiel Tenant Admin",
+ "title": "Welcome to your event studio",
+ "subtitle": "Begin with an inspired introduction, secure your package, and craft the perfect guest gallery – all optimised for mobile hosts.",
+ "alreadyFamiliar": "Already familiar with Fotospiel?",
+ "jumpToDashboard": "Jump to dashboard"
+ },
+ "hero": {
+ "eyebrow": "Your event, your stage",
+ "title": "Design the next Fotospiel experience",
+ "scriptTitle": "Memorable for guests, effortless for you.",
+ "description": "In just a few steps you guide guests through a magical photo journey – complete with storytelling, tasks, and a moderated gallery.",
+ "primary": {
+ "label": "Explore packages",
+ "button": "Explore packages"
+ },
+ "secondary": {
+ "label": "View events",
+ "button": "View existing events"
+ }
+ },
+ "highlights": {
+ "gallery": {
+ "title": "Premium guest gallery",
+ "description": "Curate photos in real time, highlight favourites, and share QR codes in a tap.",
+ "badge": "New"
+ },
+ "team": {
+ "title": "Flexible team onboarding",
+ "description": "Invite co-hosts, assign roles, and stay on top of moderation and tasks."
+ },
+ "story": {
+ "title": "Storytelling in chapters",
+ "description": "Guided tasks and emotion cards turn every event into a memorable journey."
+ }
+ },
+ "ctaList": {
+ "choosePackage": {
+ "label": "Choose your package",
+ "description": "Reserve credits or subscriptions to activate events instantly. Flexible options for any event size.",
+ "button": "Continue to packages"
+ },
+ "createEvent": {
+ "label": "Prepare event",
+ "description": "Collect event details, plan tasks, and ensure a smooth flow before the big day.",
+ "button": "Go to event manager"
+ }
+ },
+ "packages": {
+ "layout": {
+ "eyebrow": "Step 2",
+ "title": "Choose your package",
+ "subtitle": "Fotospiel supports flexible pricing: single-use credits or subscriptions covering multiple events."
+ },
+ "step": {
+ "title": "Activate the right credits",
+ "description": "Secure capacity for your next event. Upgrade at any time – only pay for what you need."
+ },
+ "state": {
+ "loading": "Loading packages …",
+ "errorTitle": "Failed to load",
+ "errorDescription": "Please try again or contact support.",
+ "emptyTitle": "Catalogue is empty",
+ "emptyDescription": "No packages are currently available. Reach out to support to enable new offers."
+ },
+ "card": {
+ "subscription": "Subscription",
+ "creditPack": "Credit pack",
+ "description": "Ready for your next event right away.",
+ "descriptionWithPhotos": "Up to {{count}} photos included – perfect for vibrant storytelling.",
+ "active": "Active package",
+ "select": "Select package",
+ "onRequest": "On request",
+ "purchased": "Purchased on {{date}}",
+ "purchasedUnknown": "unknown date",
+ "badges": {
+ "guests": "{{count}} guests",
+ "days": "{{count}} gallery days",
+ "photos": "{{count}} photos"
+ }
+ },
+ "features": {
+ "subscription": "Subscription",
+ "priority_support": "Priority support",
+ "custom_domain": "Custom domain",
+ "analytics": "Analytics",
+ "team_management": "Team management",
+ "moderation_tools": "Moderation tools",
+ "prints": "Print uploads"
+ },
+ "cta": {
+ "billing": {
+ "label": "Go to billing",
+ "description": "Already know what you need? Jump straight to the billing area you know.",
+ "button": "Open billing"
+ },
+ "summary": {
+ "label": "View order summary",
+ "description": "Review package details and decide whether to pay now or later.",
+ "button": "Continue to summary"
+ }
+ }
+ },
+ "summary": {
+ "layout": {
+ "eyebrow": "Step 3",
+ "title": "Order summary",
+ "subtitle": "Review package, price, and payment before proceeding to the event setup."
+ },
+ "footer": {
+ "back": "Back to package selection"
+ },
+ "step": {
+ "title": "Your selection at a glance",
+ "description": "Hand off to billing now or continue setup and pay later."
+ },
+ "state": {
+ "loading": "Checking available packages …",
+ "errorTitle": "Package data temporarily unavailable",
+ "errorDescription": "Please try again or contact support.",
+ "missingTitle": "No package selected",
+ "missingDescription": "Select a package first or refresh if data changed."
+ },
+ "details": {
+ "subscription": "Subscription",
+ "creditPack": "Credit pack",
+ "photos": "Up to {{count}} photos",
+ "galleryDays": "{{count}} gallery days",
+ "guests": "{{count}} guests",
+ "infinity": "∞",
+ "features": {
+ "subscription": "Subscription",
+ "priority_support": "Priority support",
+ "custom_domain": "Custom domain",
+ "analytics": "Analytics",
+ "team_management": "Team management",
+ "moderation_tools": "Moderation tools",
+ "prints": "Print uploads"
+ },
+ "section": {
+ "photosTitle": "Photos & gallery",
+ "photosValue": "Up to {{count}} photos, gallery {{days}} days",
+ "photosUnlimited": "Unlimited photos, flexible gallery",
+ "guestsTitle": "Guests & team",
+ "guestsValue": "{{count}} guests included, co-hosts flexible",
+ "guestsUnlimited": "Unlimited guest list",
+ "featuresTitle": "Highlights",
+ "featuresNone": "Standard",
+ "statusTitle": "Status",
+ "statusActive": "Already purchased",
+ "statusInactive": "Not purchased yet"
+ }
+ },
+ "status": {
+ "pendingTitle": "Payment still pending",
+ "pendingDescription": "You can start preparing the event. An active package is required before going live."
+ },
+ "free": {
+ "description": "This package is free. Assign it to your tenant and continue immediately.",
+ "activate": "Activate free package",
+ "progress": "Activating …",
+ "successTitle": "Free package activated",
+ "successDescription": "Credits added. Continue with the setup.",
+ "failureTitle": "Activation failed",
+ "errorMessage": "The free package could not be activated."
+ },
+ "stripe": {
+ "sectionTitle": "Card payment (Stripe)",
+ "heading": "Card payment",
+ "notReady": "Payment module not ready yet. Please refresh.",
+ "genericError": "Payment failed. Please try again.",
+ "missingPaymentId": "Could not confirm payment (missing payment ID).",
+ "completionFailed": "Purchase not recorded yet. Contact support with your payment confirmation.",
+ "errorTitle": "Payment failed",
+ "submitting": "Confirming payment …",
+ "submit": "Pay now",
+ "hint": "Secure checkout via Stripe. You'll receive confirmation once recorded.",
+ "loading": "Loading payment details …",
+ "unavailableTitle": "Stripe unavailable",
+ "unavailableDescription": "Stripe could not be initialised.",
+ "missingKey": "Stripe publishable key missing. Configure VITE_STRIPE_PUBLISHABLE_KEY.",
+ "intentFailed": "Stripe could not prepare the payment."
+ },
+ "paypal": {
+ "sectionTitle": "PayPal",
+ "heading": "PayPal",
+ "createFailed": "PayPal order could not be created. Please try again.",
+ "captureFailed": "PayPal payment could not be captured. Contact support if funds were withdrawn.",
+ "errorTitle": "PayPal error",
+ "genericError": "PayPal reported a problem. Please try again later.",
+ "missingOrderId": "PayPal did not return an order ID.",
+ "cancelled": "PayPal payment was cancelled.",
+ "hint": "PayPal may redirect you briefly to confirm. You'll return automatically afterwards.",
+ "notConfiguredTitle": "PayPal not configured",
+ "notConfiguredDescription": "Provide VITE_PAYPAL_CLIENT_ID so hosts can pay with PayPal."
+ },
+ "nextStepsTitle": "Next steps",
+ "nextSteps": [
+ "Optional: finish billing (Stripe/PayPal) inside the billing area.",
+ "Complete the event setup and configure tasks, team, and gallery.",
+ "Check credits before go-live and share your guest link."
+ ],
+ "cta": {
+ "billing": {
+ "label": "Start billing",
+ "description": "Opens the billing area with Stripe, PayPal, and credit options.",
+ "button": "Go to billing"
+ },
+ "setup": {
+ "label": "Continue to event setup",
+ "description": "You can return to billing any time.",
+ "button": "Continue to setup"
+ }
+ }
+ },
+ "eventSetup": {
+ "layout": {
+ "eyebrow": "Step 4",
+ "title": "Prepare your first event",
+ "subtitle": "Fill in a few details, invite co-hosts, and open your guest gallery for the big day."
+ },
+ "step": {
+ "title": "Event setup in minutes",
+ "description": "We guide you through name, date, mood, and tasks. Afterwards you can moderate photos and support guests live."
+ },
+ "tiles": {
+ "story": {
+ "title": "Story & mood",
+ "copy": "Pick imagery, colours, and emotion cards for your event."
+ },
+ "team": {
+ "title": "Organise your team",
+ "copy": "Invite moderators or photographers and assign roles."
+ },
+ "launch": {
+ "title": "Prepare go-live",
+ "copy": "Create QR codes, test the gallery, and align the run of show."
+ }
+ },
+ "cta": {
+ "heading": "Ready for your first event?",
+ "description": "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
+ "button": "Create event"
+ },
+ "actions": {
+ "back": {
+ "label": "Review packages again",
+ "description": "Compare pricing or update your current package.",
+ "button": "Back to packages"
+ },
+ "dashboard": {
+ "label": "Go to dashboard",
+ "description": "Jump into management to edit existing events.",
+ "button": "Open dashboard"
+ },
+ "events": {
+ "label": "Event overview",
+ "description": "Keep track of active and archived events.",
+ "button": "Open event list"
+ }
+ }
+ }
+}
diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx
index 365a591..a9e13c1 100644
--- a/resources/js/admin/main.tsx
+++ b/resources/js/admin/main.tsx
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './auth/context';
import { router } from './router';
import '../../css/app.css';
+import './i18n';
import { initializeTheme } from '@/hooks/use-appearance';
import { OnboardingProgressProvider } from './onboarding';
diff --git a/resources/js/admin/onboarding/components/TenantWelcomeLayout.tsx b/resources/js/admin/onboarding/components/TenantWelcomeLayout.tsx
index 25f6d31..19bf328 100644
--- a/resources/js/admin/onboarding/components/TenantWelcomeLayout.tsx
+++ b/resources/js/admin/onboarding/components/TenantWelcomeLayout.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { cn } from '@/lib/utils';
+import { LanguageSwitcher } from '../../components/LanguageSwitcher';
export interface TenantWelcomeLayoutProps {
eyebrow?: string;
@@ -46,7 +46,10 @@ export function TenantWelcomeLayout({
)}
- {headerAction &&
{headerAction}
}
+
+
+ {headerAction}
+
@@ -64,4 +67,4 @@ export function TenantWelcomeLayout({
);
}
-TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';
\ No newline at end of file
+TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';
diff --git a/resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx b/resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx
index ee53f03..b3be3eb 100644
--- a/resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx
+++ b/resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx
@@ -1,54 +1,57 @@
-import React from 'react';
-import { useNavigate } from 'react-router-dom';
-import { ClipboardCheck, Sparkles, Globe, ArrowRight } from 'lucide-react';
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { ClipboardCheck, Sparkles, Globe, ArrowRight } from "lucide-react";
+
import {
TenantWelcomeLayout,
WelcomeStepCard,
OnboardingCTAList,
useOnboardingProgress,
-} from '..';
-import { Button } from '@/components/ui/button';
-import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from '../../constants';
+} from "..";
+import { Button } from "@/components/ui/button";
+import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from "../../constants";
export default function WelcomeEventSetupPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
+ const { t } = useTranslation("onboarding");
React.useEffect(() => {
- markStep({ lastStep: 'event-setup' });
+ markStep({ lastStep: "event-setup" });
}, [markStep]);
return (
{[
{
- id: 'story',
- title: 'Story & Stimmung',
- copy: 'Whle Bildsprache, Farben und Emotionskarten fr dein Event.',
+ id: "story",
+ title: t("eventSetup.tiles.story.title"),
+ copy: t("eventSetup.tiles.story.copy"),
icon: Sparkles,
},
{
- id: 'team',
- title: 'Team organisieren',
- copy: 'Lade Moderator*innen oder Fotograf*innen ein und teile Rollen zu.',
+ id: "team",
+ title: t("eventSetup.tiles.team.title"),
+ copy: t("eventSetup.tiles.team.copy"),
icon: Globe,
},
{
- id: 'launch',
- title: 'Go-Live vorbereiten',
- copy: 'Erstelle QR-Codes, teste die Gstegalerie und kommuniziere den Ablauf.',
+ id: "launch",
+ title: t("eventSetup.tiles.launch.title"),
+ copy: t("eventSetup.tiles.launch.copy"),
icon: ArrowRight,
},
].map((item) => (
@@ -66,20 +69,17 @@ export default function WelcomeEventSetupPage() {
-
Bereit fr dein erstes Event?
-
- Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die
- Gstegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurckkehren.
-
{!activePackage && (
- Abrechnung steht noch aus
-
- Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket.
-
+ {t("summary.status.pendingTitle")}
+ {t("summary.status.pendingDescription")}
)}
{packageDetails.price === 0 && (
-
- Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup
- weitermachen.
-
- Credits und Status
+ {t('management.billing.sections.overview.title', 'Credits und Status')}
- Dein aktuelles Guthaben und das aktive Reseller Paket.
+ {t('management.billing.sections.overview.description', 'Dein aktuelles Guthaben und das aktive Reseller Paket.')}
@@ -134,24 +186,30 @@ export default function BillingPage() {
-
+
@@ -160,18 +218,25 @@ export default function BillingPage() {
- Paket Historie
+ {t('management.billing.packages.title', 'Paket Historie')}
- Uebersicht ueber aktive und vergangene Reseller Pakete.
+ {t('management.billing.packages.description', 'Übersicht über aktive und vergangene Reseller Pakete.')}
{packages.length === 0 ? (
-
+
) : (
packages.map((pkg) => (
-
+
))
)}
@@ -182,28 +247,33 @@ export default function BillingPage() {
- Credit Ledger
+ {t('management.billing.ledger.title', 'Credit Ledger')}
- Alle Zu- und Abbuchungen deines Credits Kontos.
+ {t('management.billing.ledger.description', 'Alle Zu- und Abbuchungen deines Credits-Kontos.')}
- Mitglieder
+ {t('management.members.sections.list.title', 'Mitglieder')}
{membersUnavailable ? (
- Feature noch nicht aktiviert
+ {t('management.members.alerts.lockedTitle', 'Feature noch nicht aktiviert')}
- Die Mitgliederverwaltung ist fuer dieses Event noch nicht verfuegbar. Bitte kontaktiere den Support,
- um das Feature freizuschalten.
+ {t(
+ 'management.members.alerts.lockedDescription',
+ 'Die Mitgliederverwaltung ist für dieses Event noch nicht verfügbar. Bitte kontaktiere den Support, um das Feature freizuschalten.'
+ )}
) : members.length === 0 ? (
-
+
) : (
);
-}
\ No newline at end of file
+}
diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx
index 85c4344..c2cbd04 100644
--- a/resources/js/guest/components/GalleryPreview.tsx
+++ b/resources/js/guest/components/GalleryPreview.tsx
@@ -82,7 +82,7 @@ export default function GalleryPreview({ slug }: Props) {
My Photos