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. -

+

{t("eventSetup.cta.heading")}

+

{t("eventSetup.cta.description")}

@@ -88,29 +88,29 @@ export default function WelcomeEventSetupPage() { navigate(-1), - variant: 'secondary', + variant: "secondary", }, { - id: 'dashboard', - label: 'Zum Dashboard', - description: 'Springe ins Management, um bestehende Events zu bearbeiten.', - buttonLabel: 'Dashboard ffnen', + id: "dashboard", + label: t("eventSetup.actions.dashboard.label"), + description: t("eventSetup.actions.dashboard.description"), + buttonLabel: t("eventSetup.actions.dashboard.button"), onClick: () => navigate(ADMIN_HOME_PATH), }, { - id: 'events', - label: 'Eventbersicht', - description: 'Behalte den berblick ber alle aktiven und archivierten Events.', - buttonLabel: 'Eventliste', + id: "events", + label: t("eventSetup.actions.events.label"), + description: t("eventSetup.actions.events.description"), + buttonLabel: t("eventSetup.actions.events.button"), onClick: () => navigate(ADMIN_EVENTS_PATH), }, ]} />
); -} \ No newline at end of file +} diff --git a/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx b/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx index f876711..a45b09d 100644 --- a/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx +++ b/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx @@ -1,6 +1,8 @@ -import React from "react"; +import React from "react"; import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { Sparkles, Users, Camera, CalendarDays, ChevronRight } from "lucide-react"; + import { TenantWelcomeLayout, WelcomeHero, @@ -13,6 +15,7 @@ import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants" export default function WelcomeLandingPage() { const navigate = useNavigate(); const { markStep } = useOnboardingProgress(); + const { t } = useTranslation("onboarding"); React.useEffect(() => { markStep({ welcomeSeen: true, lastStep: "landing" }); @@ -20,38 +23,36 @@ export default function WelcomeLandingPage() { return ( - Schon vertraut mit Fotospiel? + {t("layout.alreadyFamiliar")} } > navigate(ADMIN_WELCOME_PACKAGES_PATH), icon: Sparkles, }, { - label: "Events anzeigen", - buttonLabel: "Bestehende Events anzeigen", + label: t("hero.secondary.label"), onClick: () => navigate(ADMIN_EVENTS_PATH), icon: CalendarDays, variant: "outline", @@ -64,24 +65,21 @@ export default function WelcomeLandingPage() { { id: "gallery", icon: Camera, - title: "Premium Gästegalerie", - description: - "Kuratiere Fotos in Echtzeit, markiere Highlights und teile QR-Codes mit einem Tap.", - badge: "Neu", + title: t("highlights.gallery.title"), + description: t("highlights.gallery.description"), + badge: t("highlights.gallery.badge"), }, { id: "team", icon: Users, - title: "Flexibles Team-Onboarding", - description: - "Lade Co-Hosts ein, weise Rollen zu und behalte den Überblick über Moderation und Aufgaben.", + title: t("highlights.team.title"), + description: t("highlights.team.description"), }, { - id: "sparkles", + id: "story", icon: Sparkles, - title: "Storytelling in Etappen", - description: - "Geführte Aufgaben und Emotionskarten machen jedes Event zu einer erinnerungswürdigen Reise.", + title: t("highlights.story.title"), + description: t("highlights.story.description"), }, ]} /> @@ -90,21 +88,19 @@ export default function WelcomeLandingPage() { actions={[ { id: "choose-package", - label: "Dein Eventpaket auswählen", - description: - "Reserviere Credits oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.", + label: t("ctaList.choosePackage.label"), + description: t("ctaList.choosePackage.description"), onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH), icon: Sparkles, - buttonLabel: "Weiter zu Paketen", + buttonLabel: t("ctaList.choosePackage.button"), }, { id: "create-event", - label: "Event vorbereiten", - description: - "Sammle Eventdetails, plane Aufgaben und sorge für einen reibungslosen Ablauf noch vor dem Tag des Events.", + label: t("ctaList.createEvent.label"), + description: t("ctaList.createEvent.description"), onClick: () => navigate(ADMIN_EVENTS_PATH), icon: CalendarDays, - buttonLabel: "Zum Event-Manager", + buttonLabel: t("ctaList.createEvent.button"), variant: "secondary", }, ]} diff --git a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx index bdd0bf3..2bf3539 100644 --- a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx +++ b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx @@ -1,39 +1,93 @@ -import React from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { Receipt, ShieldCheck, ArrowRight, ArrowLeft, CreditCard, AlertTriangle, Loader2 } from 'lucide-react'; +import React from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + Receipt, + ShieldCheck, + ArrowRight, + ArrowLeft, + CreditCard, + AlertTriangle, + 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 { TenantWelcomeLayout, WelcomeStepCard, OnboardingCTAList, useOnboardingProgress, -} from '..'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from '../../constants'; -import { useTenantPackages } from '../hooks/useTenantPackages'; +} from ".."; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants"; +import { useTenantPackages } from "../hooks/useTenantPackages"; import { assignFreeTenantPackage, completeTenantPackagePurchase, createTenantPackagePaymentIntent, createTenantPayPalOrder, captureTenantPayPalOrder, -} from '../../api'; -import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'; -import { loadStripe } from '@stripe/stripe-js'; -import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js'; +} from "../../api"; -const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ''; -const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? ''; +const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; +const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? ""; const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null; type StripeCheckoutProps = { clientSecret: string; packageId: number; onSuccess: () => void; + t: ReturnType["t"]; }; -function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeCheckoutProps) { +type PayPalCheckoutProps = { + packageId: number; + onSuccess: () => void; + t: ReturnType["t"]; + currency?: string; +}; + +function useLocaleFormats(locale: string) { + const currencyFormatter = React.useMemo( + () => + new Intl.NumberFormat(locale, { + style: "currency", + currency: "EUR", + minimumFractionDigits: 0, + }), + [locale] + ); + + const dateFormatter = React.useMemo( + () => + new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + }), + [locale] + ); + + return { currencyFormatter, dateFormatter }; +} + +function humanizeFeature(t: ReturnType["t"], key: string): string { + const translationKey = `summary.details.features.${key}`; + const translated = t(translationKey); + if (translated !== translationKey) { + return translated; + } + return key + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) { const stripe = useStripe(); const elements = useElements(); const [submitting, setSubmitting] = React.useState(false); @@ -42,7 +96,7 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!stripe || !elements) { - setError('Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.'); + setError(t("summary.stripe.notReady")); return; } @@ -54,25 +108,25 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko confirmParams: { return_url: window.location.href, }, - redirect: 'if_required', + redirect: "if_required", }); if (result.error) { - setError(result.error.message ?? 'Zahlung fehlgeschlagen. Bitte erneut versuchen.'); + setError(result.error.message ?? t("summary.stripe.genericError")); setSubmitting(false); return; } const paymentIntent = result.paymentIntent; const paymentMethodId = - typeof paymentIntent?.payment_method === 'string' + typeof paymentIntent?.payment_method === "string" ? paymentIntent.payment_method - : typeof paymentIntent?.id === 'string' - ? paymentIntent.id - : null; + : typeof paymentIntent?.id === "string" + ? paymentIntent.id + : null; if (!paymentMethodId) { - setError('Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).'); + setError(t("summary.stripe.missingPaymentId")); setSubmitting(false); return; } @@ -84,11 +138,11 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko }); onSuccess(); } catch (purchaseError) { - console.error('[Onboarding] Purchase completion failed', purchaseError); + console.error("[Onboarding] Purchase completion failed", purchaseError); setError( purchaseError instanceof Error ? purchaseError.message - : 'Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.' + : t("summary.stripe.completionFailed") ); setSubmitting(false); } @@ -97,12 +151,12 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko return (
-

Kartenzahlung

+

{t("summary.stripe.heading")}

{error && ( - Zahlung fehlgeschlagen + {t("summary.stripe.errorTitle")} {error} )} @@ -114,110 +168,94 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko {submitting ? ( <> - Zahlung wird bestätigt ... + {t("summary.stripe.submitting")} ) : ( <> - Jetzt bezahlen + {t("summary.stripe.submit")} )} -

- Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde. -

- +

{t("summary.stripe.hint")}

); } -type PayPalCheckoutProps = { - packageId: number; - onSuccess: () => void; - currency?: string; -}; - -function PayPalCheckout({ packageId, onSuccess, currency = 'EUR' }: PayPalCheckoutProps) { - const [status, setStatus] = React.useState<'idle' | 'creating' | 'capturing' | 'error' | 'success'>('idle'); +function PayPalCheckout({ packageId, onSuccess, t, currency = "EUR" }: PayPalCheckoutProps) { + const [status, setStatus] = React.useState<"idle" | "creating" | "capturing" | "error" | "success">("idle"); const [error, setError] = React.useState(null); const handleCreateOrder = React.useCallback(async () => { try { - setStatus('creating'); + setStatus("creating"); const orderId = await createTenantPayPalOrder(packageId); - setStatus('idle'); + setStatus("idle"); setError(null); return orderId; } catch (err) { - console.error('[Onboarding] PayPal create order failed', err); - setStatus('error'); + console.error("[Onboarding] PayPal create order failed", err); + setStatus("error"); setError( - err instanceof Error - ? err.message - : 'PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.' + err instanceof Error ? err.message : t("summary.paypal.createFailed") ); throw err; } - }, [packageId]); + }, [packageId, t]); const handleApprove = React.useCallback( async (orderId: string) => { try { - setStatus('capturing'); + setStatus("capturing"); await captureTenantPayPalOrder(orderId); - setStatus('success'); + setStatus("success"); setError(null); onSuccess(); } catch (err) { - console.error('[Onboarding] PayPal capture failed', err); - setStatus('error'); + console.error("[Onboarding] PayPal capture failed", err); + setStatus("error"); setError( - err instanceof Error - ? err.message - : 'PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.' + err instanceof Error ? err.message : t("summary.paypal.captureFailed") ); throw err; } }, - [onSuccess] + [onSuccess, t] ); return (
-

PayPal

+

{t("summary.paypal.heading")}

{error && ( - PayPal-Fehler + {t("summary.paypal.errorTitle")} {error} )} handleCreateOrder()} onApprove={async (data) => { if (!data.orderID) { - setError('PayPal hat keine Order-ID geliefert.'); - setStatus('error'); + setError(t("summary.paypal.missingOrderId")); + setStatus("error"); return; } await handleApprove(data.orderID); }} onError={(err) => { - console.error('[Onboarding] PayPal onError', err); - setStatus('error'); - setError('PayPal hat ein Problem gemeldet. Bitte versuche es in wenigen Minuten erneut.'); + console.error("[Onboarding] PayPal onError", err); + setStatus("error"); + setError(t("summary.paypal.genericError")); }} onCancel={() => { - setStatus('idle'); - setError('PayPal-Zahlung wurde abgebrochen.'); + setStatus("idle"); + setError(t("summary.paypal.cancelled")); }} - disabled={status === 'creating' || status === 'capturing'} + disabled={status === "creating" || status === "capturing"} /> -

- PayPal leitet dich ggf. kurz weiter, um die Zahlung zu bestätigen. Anschließend wirst du automatisch zum Setup - zurückgebracht. -

+

{t("summary.paypal.hint")}

); } @@ -227,27 +265,30 @@ export default function WelcomeOrderSummaryPage() { const location = useLocation(); const { progress, markStep } = useOnboardingProgress(); const packagesState = useTenantPackages(); + const { t, i18n } = useTranslation("onboarding"); + const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE"; + const { currencyFormatter, dateFormatter } = useLocaleFormats(locale); - const packageIdFromState = typeof location.state === 'object' ? (location.state as any)?.packageId : undefined; + const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined; const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null; React.useEffect(() => { - if (!selectedPackageId && packagesState.status !== 'loading') { + if (!selectedPackageId && packagesState.status !== "loading") { navigate(ADMIN_WELCOME_PACKAGES_PATH, { replace: true }); } }, [selectedPackageId, packagesState.status, navigate]); React.useEffect(() => { - markStep({ lastStep: 'summary' }); + markStep({ lastStep: "summary" }); }, [markStep]); const packageDetails = - packagesState.status === 'success' && selectedPackageId + packagesState.status === "success" && selectedPackageId ? packagesState.catalog.find((pkg) => pkg.id === selectedPackageId) : null; const activePackage = - packagesState.status === 'success' + packagesState.status === "success" ? packagesState.purchasedPackages.find((pkg) => pkg.package_id === selectedPackageId) : null; @@ -255,65 +296,86 @@ export default function WelcomeOrderSummaryPage() { const requiresPayment = Boolean(packageDetails && packageDetails.price > 0); const [clientSecret, setClientSecret] = React.useState(null); - const [intentStatus, setIntentStatus] = React.useState<'idle' | 'loading' | 'error' | 'ready'>('idle'); + const [intentStatus, setIntentStatus] = React.useState<"idle" | "loading" | "error" | "ready">("idle"); const [intentError, setIntentError] = React.useState(null); - const [freeAssignStatus, setFreeAssignStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle"); const [freeAssignError, setFreeAssignError] = React.useState(null); React.useEffect(() => { if (!requiresPayment || !packageDetails) { setClientSecret(null); - setIntentStatus('idle'); + setIntentStatus("idle"); setIntentError(null); return; } if (!stripePromise) { - setIntentError('Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.'); - setIntentStatus('error'); + setIntentError(t("summary.stripe.missingKey")); + setIntentStatus("error"); return; } let cancelled = false; - setIntentStatus('loading'); + setIntentStatus("loading"); setIntentError(null); + createTenantPackagePaymentIntent(packageDetails.id) .then((secret) => { if (cancelled) return; setClientSecret(secret); - setIntentStatus('ready'); + setIntentStatus("ready"); }) .catch((error) => { - console.error('[Onboarding] Failed to create payment intent', error); + console.error("[Onboarding] Payment intent failed", error); if (cancelled) return; - setIntentError( - error instanceof Error - ? error.message - : 'PaymentIntent konnte nicht erstellt werden. Bitte später erneut versuchen.' - ); - setIntentStatus('error'); + setIntentStatus("error"); + setIntentError(error instanceof Error ? error.message : t("summary.stripe.intentFailed")); }); return () => { cancelled = true; }; - }, [requiresPayment, packageDetails?.id]); + }, [requiresPayment, packageDetails, t]); const priceText = progress.selectedPackage?.priceText ?? - (packageDetails && typeof packageDetails.price === 'number' - ? new Intl.NumberFormat('de-DE', { - style: 'currency', - currency: 'EUR', - minimumFractionDigits: 0, - }).format(packageDetails.price) + (packageDetails && typeof packageDetails.price === "number" + ? currencyFormatter.format(packageDetails.price) : null); + const detailBadges = React.useMemo(() => { + if (!packageDetails) { + return [] as string[]; + } + const badges: string[] = []; + if (packageDetails.max_photos) { + badges.push(t("summary.details.photos", { count: packageDetails.max_photos })); + } + if (packageDetails.gallery_days) { + badges.push(t("summary.details.galleryDays", { count: packageDetails.gallery_days })); + } + if (packageDetails.max_guests) { + badges.push(t("summary.details.guests", { count: packageDetails.max_guests })); + } + return badges; + }, [packageDetails, t]); + + const featuresList = React.useMemo(() => { + if (!packageDetails) { + return [] as string[]; + } + return Object.entries(packageDetails.features ?? {}) + .filter(([, enabled]) => Boolean(enabled)) + .map(([feature]) => humanizeFeature(t, feature)); + }, [packageDetails, t]); + + const nextSteps = t("summary.nextSteps", { returnObjects: true }) as string[]; + return ( navigate(ADMIN_WELCOME_PACKAGES_PATH)} > - Zurück zur Paketauswahl + {t("summary.footer.back")} } > - {packagesState.status === 'loading' && ( + {packagesState.status === "loading" && (
- Wir prüfen verfügbare Pakete … + {t("summary.state.loading")}
)} - {packagesState.status === 'error' && ( + {packagesState.status === "error" && ( - Paketdaten derzeit nicht verfügbar + {t("summary.state.errorTitle")} - {packagesState.message} Du kannst das Event trotzdem vorbereiten und später zurückkehren. + {packagesState.message ?? t("summary.state.errorDescription")} )} - {packagesState.status === 'success' && !packageDetails && ( + {packagesState.status === "success" && !packageDetails && ( - Keine Paketauswahl gefunden - - Bitte wähle zuerst ein Paket aus oder aktualisiere die Seite, falls sich die Daten geändert haben. - + {t("summary.state.missingTitle")} + {t("summary.state.missingDescription")} )} - {packagesState.status === 'success' && packageDetails && ( + {packagesState.status === "success" && packageDetails && (

- {isSubscription ? 'Abo' : 'Credit-Paket'} + {t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}

{packageDetails.name}

@@ -376,59 +436,59 @@ export default function WelcomeOrderSummaryPage() {
-
Fotos & Galerie
+
{t("summary.details.section.photosTitle")}
{packageDetails.max_photos - ? `Bis zu ${packageDetails.max_photos} Fotos, Galerie ${packageDetails.gallery_days ?? '∞'} Tage` - : 'Unbegrenzte Fotos, flexible Galerie'} + ? t("summary.details.section.photosValue", { + count: packageDetails.max_photos, + days: packageDetails.gallery_days ?? t("summary.details.infinity"), + }) + : t("summary.details.section.photosUnlimited")}
-
Gäste & Team
+
{t("summary.details.section.guestsTitle")}
{packageDetails.max_guests - ? `${packageDetails.max_guests} Gäste inklusive, Co-Hosts frei planbar` - : 'Unbegrenzte Gästeliste'} + ? t("summary.details.section.guestsValue", { count: packageDetails.max_guests }) + : t("summary.details.section.guestsUnlimited")}
-
Highlights
-
- {Object.entries(packageDetails.features ?? {}) - .filter(([, enabled]) => enabled) - .map(([feature]) => feature.replace(/_/g, ' ')) - .join(', ') || 'Standard'} -
+
{t("summary.details.section.featuresTitle")}
+
{featuresList.length ? featuresList.join(", ") : t("summary.details.section.featuresNone")}
-
Status
-
{activePackage ? 'Bereits gebucht' : 'Noch nicht gebucht'}
+
{t("summary.details.section.statusTitle")}
+
{activePackage ? t("summary.details.section.statusActive") : t("summary.details.section.statusInactive")}
+ {detailBadges.length > 0 && ( +
+ {detailBadges.map((badge) => ( + + {badge} + + ))} +
+ )}
{!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. -

- {freeAssignStatus === 'success' ? ( +

{t("summary.free.description")}

+ {freeAssignStatus === "success" ? ( - Gratis-Paket aktiviert - - Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup. - + {t("summary.free.successTitle")} + {t("summary.free.successDescription")} ) : ( )} - {freeAssignStatus === 'error' && freeAssignError && ( + {freeAssignStatus === "error" && freeAssignError && ( - Aktivierung fehlgeschlagen + {t("summary.free.failureTitle")} {freeAssignError} )} @@ -476,20 +536,20 @@ export default function WelcomeOrderSummaryPage() { {requiresPayment && (
-

Kartenzahlung (Stripe)

- {intentStatus === 'loading' && ( +

{t("summary.stripe.sectionTitle")}

+ {intentStatus === "loading" && (
- Zahlungsdetails werden geladen … + {t("summary.stripe.loading")}
)} - {intentStatus === 'error' && ( + {intentStatus === "error" && ( - Stripe nicht verfügbar - {intentError} + {t("summary.stripe.unavailableTitle")} + {intentError ?? t("summary.stripe.unavailableDescription")} )} - {intentStatus === 'ready' && clientSecret && stripePromise && ( + {intentStatus === "ready" && clientSecret && stripePromise && ( )} @@ -505,9 +566,14 @@ export default function WelcomeOrderSummaryPage() { {paypalClientId ? (
-

PayPal

+

{t("summary.paypal.sectionTitle")}

) : ( - PayPal nicht konfiguriert - - Hinterlege `VITE_PAYPAL_CLIENT_ID`, damit Gastgeber optional mit PayPal bezahlen können. - + {t("summary.paypal.notConfiguredTitle")} + {t("summary.paypal.notConfiguredDescription")} )}
)}
-

Nächste Schritte

+

{t("summary.nextStepsTitle")}

    -
  1. Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.
  2. -
  3. Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.
  4. -
  5. Vor dem Go-Live Credits prüfen und Gäste-Link teilen.
  6. + {nextSteps.map((step, index) => ( +
  7. {step}
  8. + ))}
@@ -544,21 +609,21 @@ export default function WelcomeOrderSummaryPage() { { - markStep({ lastStep: 'event-setup' }); + markStep({ lastStep: "event-setup" }); navigate(ADMIN_WELCOME_EVENT_PATH); }, icon: ArrowRight, @@ -568,12 +633,3 @@ export default function WelcomeOrderSummaryPage() { ); } - - - - - - - - - diff --git a/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx b/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx index dfefe0e..4536482 100644 --- a/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx +++ b/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx @@ -1,60 +1,95 @@ -import React from 'react'; -import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; +import React from "react"; +import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + import { TenantWelcomeLayout, WelcomeStepCard, OnboardingCTAList, useOnboardingProgress, -} from '..'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from '../../constants'; -import { useTenantPackages } from '../hooks/useTenantPackages'; -import { Package } from '../../api'; +} from ".."; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from "../../constants"; +import { useTenantPackages } from "../hooks/useTenantPackages"; +import { Package } from "../../api"; + +const DEFAULT_CURRENCY = "EUR"; + +function useLocaleFormats(locale: string) { + const currencyFormatter = React.useMemo( + () => + new Intl.NumberFormat(locale, { + style: "currency", + currency: DEFAULT_CURRENCY, + minimumFractionDigits: 0, + }), + [locale] + ); + + const dateFormatter = React.useMemo( + () => + new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + }), + [locale] + ); + + return { currencyFormatter, dateFormatter }; +} + +function formatFeatureLabel(t: ReturnType["t"], key: string): string { + const translationKey = `packages.features.${key}`; + const translated = t(translationKey); + if (translated !== translationKey) { + return translated; + } + return key + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} export default function WelcomePackagesPage() { const navigate = useNavigate(); const { markStep } = useOnboardingProgress(); const packagesState = useTenantPackages(); + const { t, i18n } = useTranslation("onboarding"); + const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE"; + const { currencyFormatter, dateFormatter } = useLocaleFormats(locale); React.useEffect(() => { - if (packagesState.status === 'success') { + if (packagesState.status === "success") { + const active = packagesState.activePackage; markStep({ - packageSelected: Boolean(packagesState.activePackage), - lastStep: 'packages', - selectedPackage: packagesState.activePackage + packageSelected: Boolean(active), + lastStep: "packages", + selectedPackage: active ? { - id: packagesState.activePackage.package_id, - name: packagesState.activePackage.package_name, - priceText: packagesState.activePackage.price - ? new Intl.NumberFormat('de-DE', { - style: 'currency', - currency: packagesState.activePackage.currency ?? 'EUR', - minimumFractionDigits: 0, - }).format(packagesState.activePackage.price) - : null, - isSubscription: false, + id: active.package_id, + name: active.package_name, + priceText: + typeof active.price === "number" + ? currencyFormatter.format(active.price) + : t("packages.card.onRequest"), + isSubscription: Boolean(active.package_limits?.subscription), } : null, }); } - }, [packagesState, markStep]); + }, [packagesState, markStep, currencyFormatter, t]); const handleSelectPackage = React.useCallback( (pkg: Package) => { const priceText = - typeof pkg.price === 'number' - ? new Intl.NumberFormat('de-DE', { - style: 'currency', - currency: 'EUR', - minimumFractionDigits: 0, - }).format(pkg.price) - : 'Auf Anfrage'; + typeof pkg.price === "number" ? currencyFormatter.format(pkg.price) : t("packages.card.onRequest"); markStep({ packageSelected: true, - lastStep: package-, + lastStep: "packages", selectedPackage: { id: pkg.id, name: pkg.name, @@ -65,131 +100,158 @@ export default function WelcomePackagesPage() { navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } }); }, - [markStep, navigate] + [currencyFormatter, markStep, navigate, t] ); + const renderPackageList = () => { + if (packagesState.status === "loading") { + return ( +
+ + {t("packages.state.loading")} +
+ ); + } + + if (packagesState.status === "error") { + return ( + + + {t("packages.state.errorTitle")} + {packagesState.message ?? t("packages.state.errorDescription")} + + ); + } + + if (packagesState.status === "success" && packagesState.catalog.length === 0) { + return ( + + {t("packages.state.emptyTitle")} + {t("packages.state.emptyDescription")} + + ); + } + + if (packagesState.status !== "success") { + return null; + } + + return ( +
+ {packagesState.catalog.map((pkg) => { + const isActive = packagesState.activePackage?.package_id === pkg.id; + const purchased = packagesState.purchasedPackages.find((tenantPkg) => tenantPkg.package_id === pkg.id); + const featureLabels = Object.entries(pkg.features ?? {}) + .filter(([, enabled]) => Boolean(enabled)) + .map(([key]) => formatFeatureLabel(t, key)); + + const isSubscription = Boolean(pkg.features?.subscription); + const priceText = + typeof pkg.price === "number" ? currencyFormatter.format(pkg.price ?? 0) : t("packages.card.onRequest"); + + const badges: string[] = []; + if (pkg.max_guests) { + badges.push(t("packages.card.badges.guests", { count: pkg.max_guests })); + } + if (pkg.gallery_days) { + badges.push(t("packages.card.badges.days", { count: pkg.gallery_days })); + } + if (pkg.max_photos) { + badges.push(t("packages.card.badges.photos", { count: pkg.max_photos })); + } + + return ( +
+
+
+

+ {t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")} +

+

{pkg.name}

+
+ {priceText} +
+

+ {pkg.max_photos + ? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos }) + : t("packages.card.description")} +

+
+ {badges.map((badge) => ( + + {badge} + + ))} + {featureLabels.map((feature) => ( + + {feature} + + ))} + {isActive && ( + + {t("packages.card.active")} + + )} +
+ + {purchased && ( +

+ {t("packages.card.purchased", { + date: purchased.purchased_at + ? dateFormatter.format(new Date(purchased.purchased_at)) + : t("packages.card.purchasedUnknown"), + })} +

+ )} +
+ ); + })} +
+ ); + }; + return ( - {packagesState.status === 'loading' && ( -
- - Pakete werden geladen -
- )} - - {packagesState.status === 'error' && ( - - - Fehler beim Laden - {packagesState.message} - - )} - - {packagesState.status === 'success' && ( -
- {packagesState.catalog.map((pkg) => { - const isActive = packagesState.activePackage?.package_id === pkg.id; - const purchased = packagesState.purchasedPackages.find((tenantPkg) => tenantPkg.package_id === pkg.id); - const featureLabels = Object.entries(pkg.features ?? {}) - .filter(([, enabled]) => Boolean(enabled)) - .map(([key]) => key.replace(/_/g, ' ')); - - const isSubscription = Boolean(pkg.features?.subscription); - const priceText = - typeof pkg.price === 'number' - ? new Intl.NumberFormat('de-DE', { - style: 'currency', - currency: 'EUR', - minimumFractionDigits: 0, - }).format(pkg.price) - : 'Auf Anfrage'; - - return ( -
-
-
-

- {isSubscription ? 'Abo' : 'Credit-Paket'} -

-

{pkg.name}

-
- {priceText} -
-

- {pkg.max_photos - ? Bis zu Fotos inklusive perfekt fr lebendige Reportagen. - : 'Sofort einsatzbereit fr dein nchstes Event.'} -

-
- {pkg.max_guests && ( - {${pkg.max_guests} Gste} - )} - {pkg.gallery_days && ( - {${pkg.gallery_days} Tage Galerie} - )} - {featureLabels.map((feature) => ( - - {feature} - - ))} - {isActive && ( - Aktives Paket - )} -
- - {purchased && ( -

- Bereits gekauft am{' '} - {purchased.purchased_at - ? new Date(purchased.purchased_at).toLocaleDateString('de-DE') - : 'unbekanntem Datum'} -

- )} -
- ); - })} -
- )} + {renderPackageList()}
navigate(ADMIN_WELCOME_SUMMARY_PATH), icon: ArrowRight, }, @@ -197,4 +259,4 @@ export default function WelcomePackagesPage() { />
); -} \ No newline at end of file +} diff --git a/resources/js/admin/pages/BillingPage.tsx b/resources/js/admin/pages/BillingPage.tsx index 8835dfc..eb00f09 100644 --- a/resources/js/admin/pages/BillingPage.tsx +++ b/resources/js/admin/pages/BillingPage.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React from 'react'; import { CreditCard, Download, Loader2, RefreshCw, Sparkles } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -24,6 +25,12 @@ type LedgerState = { }; export default function BillingPage() { + const { t, i18n } = useTranslation(['management', 'dashboard']); + const locale = React.useMemo( + () => (i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'), + [i18n.language] + ); + const [balance, setBalance] = React.useState(0); const [packages, setPackages] = React.useState([]); const [activePackage, setActivePackage] = React.useState(null); @@ -32,6 +39,51 @@ export default function BillingPage() { const [error, setError] = React.useState(null); const [loadingMore, setLoadingMore] = React.useState(false); + const formatDate = React.useCallback( + (value: string | null | undefined) => { + if (!value) return '--'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '--'; + return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' }); + }, + [locale] + ); + + const formatCurrency = React.useCallback( + (value: number | null | undefined, currency = 'EUR') => { + if (value === null || value === undefined) return '--'; + return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value); + }, + [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'), + }), + [t] + ); + React.useEffect(() => { void loadAll(); }, []); @@ -62,7 +114,7 @@ export default function BillingPage() { } } catch (err) { if (!isAuthError(err)) { - setError('Billing Daten konnten nicht geladen werden.'); + setError(t('management.billing.errors.load', 'Billing Daten konnten nicht geladen werden.')); } } finally { setLoading(false); @@ -87,7 +139,7 @@ export default function BillingPage() { }); } catch (err) { if (!isAuthError(err)) { - setError('Weitere Ledger Eintraege konnten nicht geladen werden.'); + setError(t('management.billing.errors.more', 'Weitere Ledger Eintraege konnten nicht geladen werden.')); } } finally { setLoadingMore(false); @@ -97,19 +149,19 @@ export default function BillingPage() { const actions = ( ); return ( {error && ( - Fehler + {t('dashboard.alerts.errorTitle', 'Fehler')} {error} )} @@ -123,10 +175,10 @@ export default function BillingPage() {
- 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.')}
{ledger.entries.length === 0 ? ( - + ) : ( <> {ledger.entries.map((entry) => ( - + ))} {ledger.meta && ledger.meta.current_page < ledger.meta.last_page && ( )} @@ -254,36 +324,68 @@ function InfoCard({ ); } -function PackageCard({ pkg, isActive }: { pkg: TenantPackageSummary; isActive: boolean }) { +function PackageCard({ + pkg, + isActive, + labels, + formatDate, + formatCurrency, +}: { + pkg: TenantPackageSummary; + isActive: boolean; + labels: { + statusActive: string; + statusInactive: string; + used: string; + available: string; + expires: string; + }; + formatDate: (value: string | null | undefined) => string; + formatCurrency: (value: number | null | undefined, currency?: string) => string; +}) { return (

{pkg.package_name}

- {formatDate(pkg.purchased_at)} - {formatCurrency(pkg.price)} {pkg.currency ?? 'EUR'} + {formatDate(pkg.purchased_at)} · {formatCurrency(pkg.price, pkg.currency ?? 'EUR')}

- {isActive ? 'Aktiv' : 'Inaktiv'} + {isActive ? labels.statusActive : labels.statusInactive}
- Genutzte Events: {pkg.used_events} - Verfuegbar: {pkg.remaining_events ?? '--'} - Ablauf: {formatDate(pkg.expires_at)} + + {labels.used}: {pkg.used_events} + + + {labels.available}: {pkg.remaining_events ?? '--'} + + + {labels.expires}: {formatDate(pkg.expires_at)} +
); } -function LedgerRow({ entry }: { entry: CreditLedgerEntry }) { +function LedgerRow({ + entry, + resolveReason, + formatDate, +}: { + entry: CreditLedgerEntry; + resolveReason: (reason: string) => string; + formatDate: (value: string | null | undefined) => string; +}) { const positive = entry.delta >= 0; return (
-

{mapReason(entry.reason)}

+

{resolveReason(entry.reason)}

{entry.note &&

{entry.note}

}
@@ -327,28 +429,3 @@ function BillingSkeleton() {
); } - -function mapReason(reason: string): string { - switch (reason) { - case 'purchase': - return 'Credit Kauf'; - case 'usage': - return 'Verbrauch'; - case 'manual': - return 'Manuelle Anpassung'; - default: - return reason; - } -} - -function formatDate(value: string | null | undefined): string { - if (!value) return '--'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '--'; - return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' }); -} - -function formatCurrency(value: number | null | undefined): string { - if (value === null || value === undefined) return '--'; - return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value); -} diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx index 46ffece..f946eef 100644 --- a/resources/js/admin/pages/DashboardPage.tsx +++ b/resources/js/admin/pages/DashboardPage.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -37,7 +38,7 @@ interface DashboardState { credits: number; activePackage: TenantPackageSummary | null; loading: boolean; - error: string | null; + errorKey: string | null; } export default function DashboardPage() { @@ -45,13 +46,14 @@ export default function DashboardPage() { const location = useLocation(); const { user } = useAuth(); const { progress, markStep } = useOnboardingProgress(); + const { t, i18n } = useTranslation(['dashboard', 'common']); const [state, setState] = React.useState({ summary: null, events: [], credits: 0, activePackage: null, loading: true, - error: null, + errorKey: null, }); React.useEffect(() => { @@ -71,19 +73,19 @@ export default function DashboardPage() { const fallbackSummary = buildSummaryFallback(events, credits.balance ?? 0, packages.activePackage); - setState({ - summary: summary ?? fallbackSummary, - events, - credits: credits.balance ?? 0, - activePackage: packages.activePackage, - loading: false, - error: null, - }); + setState({ + summary: summary ?? fallbackSummary, + events, + credits: credits.balance ?? 0, + activePackage: packages.activePackage, + loading: false, + errorKey: null, + }); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, - error: 'Dashboard konnte nicht geladen werden.', + errorKey: 'loadFailed', loading: false, })); } @@ -95,7 +97,7 @@ export default function DashboardPage() { }; }, []); - const { summary, events, credits, activePackage, loading, error } = state; + const { summary, events, credits, activePackage, loading, errorKey } = state; React.useEffect(() => { if (loading) { @@ -110,6 +112,12 @@ 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 dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; + const upcomingEvents = getUpcomingEvents(events); const publishedEvents = events.filter((event) => event.status === 'published'); @@ -119,10 +127,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)} > - Neues Event + {t('dashboard.actions.newEvent')} {events.length === 0 && ( )} ); return ( - - {error && ( + + {errorMessage && ( - Fehler - {error} + {t('dashboard.alerts.errorTitle')} + {errorMessage} )} @@ -158,24 +162,24 @@ export default function DashboardPage() { - Starte mit der Welcome Journey + {t('dashboard.welcomeCard.title')} - Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit - geführten Schritten. + Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit + geführten Schritten.
-

Wir begleiten dich durch Pakete, Aufgaben und Galerie-Konfiguration, damit dein Event glänzt.

-

Du kannst jederzeit zur Welcome Journey zurückkehren, auch wenn bereits Events laufen.

+

{t('dashboard.welcomeCard.body1')}

+

{t('dashboard.welcomeCard.body2')}

@@ -186,37 +190,37 @@ export default function DashboardPage() {
- Kurzer Ueberblick + {t('dashboard.overview.title')} - Wichtigste Kennzahlen deines Tenants auf einen Blick. + {t('dashboard.overview.description')}
- {activePackage?.package_name ?? 'Kein aktives Package'} + {activePackage?.package_name ?? t('dashboard.overview.noPackage')} } /> } /> } /> } /> @@ -225,35 +229,35 @@ export default function DashboardPage() {
- Schnellaktionen + {t('dashboard.quickActions.title')} - Starte durch mit den wichtigsten Aktionen. + {t('dashboard.quickActions.description')}
} - label="Event erstellen" - description="Plane dein naechstes Highlight." + label={t('dashboard.quickActions.createEvent.label')} + description={t('dashboard.quickActions.createEvent.description')} onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)} /> } - label="Fotos moderieren" - description="Pruefe neue Uploads." + label={t('dashboard.quickActions.moderatePhotos.label')} + description={t('dashboard.quickActions.moderatePhotos.description')} onClick={() => navigate(ADMIN_EVENTS_PATH)} /> } - label="Tasks organisieren" - description="Sorge fuer klare Verantwortungen." + label={t('dashboard.quickActions.organiseTasks.label')} + description={t('dashboard.quickActions.organiseTasks.description')} onClick={() => navigate(ADMIN_TASKS_PATH)} /> } - label="Credits verwalten" - description="Sieh dir Balance & Ledger an." + label={t('dashboard.quickActions.manageCredits.label')} + description={t('dashboard.quickActions.manageCredits.description')} onClick={() => navigate(ADMIN_BILLING_PATH)} /> @@ -262,21 +266,21 @@ export default function DashboardPage() {
- Kommende Events + {t('dashboard.upcoming.title')} - Die naechsten Termine inklusive Status & Zugriff. + {t('dashboard.upcoming.description')}
{upcomingEvents.length === 0 ? ( navigate(adminPath('/events/new'))} /> ) : ( @@ -285,6 +289,13 @@ export default function DashboardPage() { key={event.id} event={event} 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'), + }} /> )) )} @@ -383,23 +394,44 @@ function QuickAction({ ); } -function UpcomingEventRow({ event, onView }: { event: TenantEvent; onView: () => void }) { +function UpcomingEventRow({ + event, + onView, + locale, + labels, +}: { + event: TenantEvent; + onView: () => void; + locale: string; + labels: { + live: string; + planning: string; + open: string; + noDate: string; + }; +}) { const date = event.event_date ? new Date(event.event_date) : null; const formattedDate = date - ? date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' }) - : 'Kein Datum'; + ? date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' }) + : labels.noDate; return (
-
-
- - {event.status === 'published' ? 'Live' : 'In Planung'} - - -
+
+ {formattedDate} +
+ + {event.status === 'published' ? labels.live : labels.planning} + + +
); diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index 4e65a86..c754f66 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -7,7 +7,17 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { AdminLayout } from '../components/AdminLayout'; -import { createInviteLink, getEvent, getEventStats, TenantEvent, EventStats as TenantEventStats, toggleEvent } from '../api'; +import { + createInviteLink, + EventJoinToken, + EventStats as TenantEventStats, + getEvent, + getEventJoinTokens, + getEventStats, + TenantEvent, + toggleEvent, + revokeEventJoinToken, +} from '../api'; import { isAuthError } from '../auth/tokens'; import { ADMIN_EVENTS_PATH, @@ -20,6 +30,7 @@ import { interface State { event: TenantEvent | null; stats: TenantEventStats | null; + tokens: EventJoinToken[]; inviteLink: string | null; error: string | null; loading: boolean; @@ -35,11 +46,14 @@ export default function EventDetailPage() { const [state, setState] = React.useState({ event: null, stats: null, + tokens: [], inviteLink: null, error: null, loading: true, busy: false, }); + const [creatingToken, setCreatingToken] = React.useState(false); + const [revokingId, setRevokingId] = React.useState(null); const load = React.useCallback(async () => { if (!slug) { @@ -49,17 +63,22 @@ export default function EventDetailPage() { setState((prev) => ({ ...prev, loading: true, error: null })); try { - const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]); + const [eventData, statsData, joinTokens] = await Promise.all([ + getEvent(slug), + getEventStats(slug), + getEventJoinTokens(slug), + ]); setState((prev) => ({ ...prev, event: eventData, stats: statsData, + tokens: joinTokens, loading: false, inviteLink: prev.inviteLink, })); } catch (err) { if (isAuthError(err)) return; - setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false })); + setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, tokens: [] })); } }, [slug]); @@ -88,26 +107,58 @@ export default function EventDetailPage() { } async function handleInvite() { - if (!slug) return; - setState((prev) => ({ ...prev, busy: true, error: null })); + if (!slug || creatingToken) return; + setCreatingToken(true); + setState((prev) => ({ ...prev, error: null })); try { - const { link } = await createInviteLink(slug); - setState((prev) => ({ ...prev, inviteLink: link, busy: false })); + const token = await createInviteLink(slug); + setState((prev) => ({ + ...prev, + inviteLink: token.url, + tokens: [token, ...prev.tokens.filter((t) => t.id !== token.id)], + })); try { - await navigator.clipboard.writeText(link); + await navigator.clipboard.writeText(token.url); } catch { // clipboard may be unavailable, ignore silently } } catch (err) { if (!isAuthError(err)) { - setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.', busy: false })); - } else { - setState((prev) => ({ ...prev, busy: false })); + setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.' })); } } + setCreatingToken(false); + } + + async function handleCopy(token: EventJoinToken) { + try { + await navigator.clipboard.writeText(token.url); + setState((prev) => ({ ...prev, inviteLink: token.url })); + } catch (err) { + console.warn('Clipboard copy failed', err); + } } - const { event, stats, inviteLink, error, loading, busy } = state; + async function handleRevoke(token: EventJoinToken) { + if (!slug || token.revoked_at) return; + setRevokingId(token.id); + setState((prev) => ({ ...prev, error: null })); + try { + const updated = await revokeEventJoinToken(slug, token.id); + setState((prev) => ({ + ...prev, + tokens: prev.tokens.map((existing) => (existing.id === updated.id ? updated : existing)), + })); + } catch (err) { + if (!isAuthError(err)) { + setState((prev) => ({ ...prev, error: 'Token konnte nicht deaktiviert werden.' })); + } + } finally { + setRevokingId(null); + } + } + + const { event, stats, tokens, inviteLink, error, loading, busy } = state; const actions = ( <> @@ -219,15 +270,32 @@ export default function EventDetailPage() { - {inviteLink && (

{inviteLink}

)} +
+ {tokens.length > 0 ? ( + tokens.map((token) => ( + handleCopy(token)} + onRevoke={() => handleRevoke(token)} + revoking={revokingId === token.id} + /> + )) + ) : ( +

+ Noch keine Einladungen erstellt. Nutze den Button, um einen neuen QR-Link zu generieren. +

+ )} +
@@ -286,6 +354,63 @@ function StatChip({ label, value }: { label: string; value: string | number }) { ); } +function JoinTokenRow({ + token, + onCopy, + onRevoke, + revoking, +}: { + token: EventJoinToken; + onCopy: () => void; + onRevoke: () => void; + revoking: boolean; +}) { + const status = getTokenStatus(token); + 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)}} +
+
+
+ + +
+
+ ); +} + function formatDate(iso: string | null): string { if (!iso) return 'Noch kein Datum'; const date = new Date(iso); @@ -295,6 +420,32 @@ function formatDate(iso: string | null): string { return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }); } +function formatDateTime(iso: string | null): string { + if (!iso) return 'unbekannt'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return 'unbekannt'; + } + return date.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getTokenStatus(token: EventJoinToken): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' { + if (token.revoked_at) return 'Deaktiviert'; + if (token.expires_at) { + const expiry = new Date(token.expires_at); + if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) { + return 'Abgelaufen'; + } + } + return token.is_active ? 'Aktiv' : 'Deaktiviert'; +} + function renderName(name: TenantEvent['name']): string { if (typeof name === 'string') { return name; diff --git a/resources/js/admin/pages/EventMembersPage.tsx b/resources/js/admin/pages/EventMembersPage.tsx index 2d144bd..ea39a01 100644 --- a/resources/js/admin/pages/EventMembersPage.tsx +++ b/resources/js/admin/pages/EventMembersPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ArrowLeft, Loader2, Mail, Sparkles, Trash2, Users } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -35,6 +36,11 @@ const emptyInvite: InviteForm = { }; export default function EventMembersPage() { + const { t, i18n } = useTranslation(['management', 'dashboard']); + const locale = React.useMemo( + () => (i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'), + [i18n.language] + ); const params = useParams<{ slug?: string }>(); const [searchParams] = useSearchParams(); const slug = params.slug ?? searchParams.get('slug') ?? null; @@ -48,9 +54,52 @@ export default function EventMembersPage() { const [inviting, setInviting] = React.useState(false); const [membersUnavailable, setMembersUnavailable] = React.useState(false); + const formatDate = React.useCallback( + (value: string | null | undefined) => { + if (!value) return '--'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '--'; + return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' }); + }, + [locale] + ); + + const roleLabels = React.useMemo( + () => ({ + tenant_admin: t('management.members.roles.tenantAdmin', 'Tenant-Admin'), + member: t('management.members.roles.member', 'Mitglied'), + guest: t('management.members.roles.guest', 'Gast'), + }), + [t] + ); + + const statusLabels = React.useMemo( + () => ({ + published: t('management.members.statuses.published', 'Veröffentlicht'), + draft: t('management.members.statuses.draft', 'Entwurf'), + active: t('management.members.statuses.active', 'Aktiv'), + }), + [t] + ); + + const resolveRole = React.useCallback( + (role: string) => roleLabels[role as keyof typeof roleLabels] ?? role, + [roleLabels] + ); + + const resolveMemberStatus = React.useCallback( + (status?: string | null) => { + if (!status) { + return statusLabels.active; + } + return statusLabels[status as keyof typeof statusLabels] ?? status; + }, + [statusLabels] + ); + React.useEffect(() => { if (!slug) { - setError('Kein Event-Slug angegeben.'); + setError(t('management.members.errors.missingSlug', 'Kein Event-Slug angegeben.')); setLoading(false); return; } @@ -71,7 +120,7 @@ export default function EventMembersPage() { if (err instanceof Error && err.message.includes('Mitgliederverwaltung')) { setMembersUnavailable(true); } else if (!isAuthError(err)) { - setError('Mitglieder konnten nicht geladen werden.'); + setError(t('management.members.errors.load', 'Mitglieder konnten nicht geladen werden.')); } } finally { if (!cancelled) { @@ -83,13 +132,13 @@ export default function EventMembersPage() { return () => { cancelled = true; }; - }, [slug]); + }, [slug, t]); async function handleInvite(event: React.FormEvent) { event.preventDefault(); if (!slug) return; if (!invite.email.trim()) { - setError('Bitte gib eine E-Mail-Adresse ein.'); + setError(t('management.members.errors.emailRequired', 'Bitte gib eine E-Mail-Adresse ein.')); return; } setInviting(true); @@ -106,7 +155,7 @@ export default function EventMembersPage() { if (err instanceof Error && err.message.includes('Mitgliederverwaltung')) { setMembersUnavailable(true); } else if (!isAuthError(err)) { - setError('Einladung konnte nicht verschickt werden.'); + setError(t('management.members.errors.invite', 'Einladung konnte nicht verschickt werden.')); } } finally { setInviting(false); @@ -121,7 +170,7 @@ export default function EventMembersPage() { setMembers((prev) => prev.filter((entry) => entry.id !== member.id)); } catch (err) { if (!isAuthError(err)) { - setError('Mitglied konnte nicht entfernt werden.'); + setError(t('management.members.errors.remove', 'Mitglied konnte nicht entfernt werden.')); } } } @@ -129,19 +178,19 @@ export default function EventMembersPage() { const actions = ( ); return ( {error && ( - Fehler + {t('dashboard.alerts.errorTitle', 'Fehler')} {error} )} @@ -150,34 +199,38 @@ export default function EventMembersPage() { ) : !event ? ( - Event nicht gefunden - Bitte kehre zur Eventliste zurueck. + {t('management.members.alerts.notFoundTitle', 'Event nicht gefunden')} + {t('management.members.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')} ) : ( <> - {renderName(event.name)} + {renderName(event.name, t)} - Status: {event.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'} + {t('management.members.eventStatus', { + status: event.status === 'published' ? statusLabels.published : statusLabels.draft, + })}

- 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 ? ( - + ) : (
{members.map((member) => ( @@ -189,13 +242,23 @@ export default function EventMembersPage() {

{member.name}

{member.email}

- Status: {member.status ?? 'aktiv'} - {member.joined_at && Beigetreten: {formatDate(member.joined_at)}} + + {t('management.members.labels.status', { + status: resolveMemberStatus(member.status), + })} + + {member.joined_at && ( + + {t('management.members.labels.joined', { + date: formatDate(member.joined_at), + })} + + )}
- {mapRole(member.role)} + {resolveRole(member.role)}
@@ -281,29 +344,9 @@ function MembersSkeleton() { ); } -function renderName(name: TenantEvent['name']): string { +function renderName(name: TenantEvent['name'], translate: (key: string, defaultValue: string) => string): string { if (typeof name === 'string') { return name; } - return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event'; -} - -function mapRole(role: string): string { - switch (role) { - case 'tenant_admin': - return 'Tenant-Admin'; - case 'member': - return 'Mitglied'; - case 'guest': - return 'Gast'; - default: - return role; - } -} - -function formatDate(value: string | null | undefined): string { - if (!value) return '--'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '--'; - return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' }); + return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? translate('management.members.events.untitled', 'Unbenanntes Event'); } diff --git a/resources/js/admin/pages/EventTasksPage.tsx b/resources/js/admin/pages/EventTasksPage.tsx index 38aed30..92719f2 100644 --- a/resources/js/admin/pages/EventTasksPage.tsx +++ b/resources/js/admin/pages/EventTasksPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ArrowLeft, Loader2, PlusCircle, Sparkles } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -21,6 +22,7 @@ import { isAuthError } from '../auth/tokens'; import { ADMIN_EVENTS_PATH } from '../constants'; export default function EventTasksPage() { + const { t } = useTranslation(['management', 'dashboard']); const params = useParams<{ slug?: string }>(); const [searchParams] = useSearchParams(); const slug = params.slug ?? searchParams.get('slug') ?? null; @@ -34,9 +36,17 @@ export default function EventTasksPage() { const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); + const statusLabels = React.useMemo( + () => ({ + published: t('management.members.statuses.published', 'Veröffentlicht'), + draft: t('management.members.statuses.draft', 'Entwurf'), + }), + [t] + ); + React.useEffect(() => { if (!slug) { - setError('Kein Event-Slug angegeben.'); + setError(t('management.tasks.errors.missingSlug', 'Kein Event-Slug angegeben.')); setLoading(false); return; } @@ -58,7 +68,7 @@ export default function EventTasksPage() { setError(null); } catch (err) { if (!isAuthError(err)) { - setError('Event-Tasks konnten nicht geladen werden.'); + setError(t('management.tasks.errors.load', 'Event-Tasks konnten nicht geladen werden.')); } } finally { if (!cancelled) { @@ -70,7 +80,7 @@ export default function EventTasksPage() { return () => { cancelled = true; }; - }, [slug]); + }, [slug, t]); async function handleAssign() { if (!event || selected.length === 0) return; @@ -84,7 +94,7 @@ export default function EventTasksPage() { setSelected([]); } catch (err) { if (!isAuthError(err)) { - setError('Tasks konnten nicht zugewiesen werden.'); + setError(t('management.tasks.errors.assign', 'Tasks konnten nicht zugewiesen werden.')); } } finally { setSaving(false); @@ -94,19 +104,19 @@ export default function EventTasksPage() { const actions = ( ); return ( {error && ( - Hinweis + {t('dashboard.alerts.errorTitle', 'Fehler')} {error} )} @@ -115,26 +125,28 @@ export default function EventTasksPage() { ) : !event ? ( - Event nicht gefunden - Bitte kehre zur Eventliste zurueck. + {t('management.tasks.alerts.notFoundTitle', 'Event nicht gefunden')} + {t('management.tasks.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')} ) : ( <> - {renderName(event.name)} + {renderName(event.name, t)} - Status: {event.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'} + {t('management.tasks.eventStatus', { + status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status, + })}

- Zugeordnete Tasks + {t('management.tasks.sections.assigned.title', 'Zugeordnete Tasks')}

{assignedTasks.length === 0 ? ( - + ) : (
{assignedTasks.map((task) => ( @@ -142,7 +154,7 @@ export default function EventTasksPage() {

{task.title}

- {mapPriority(task.priority)} + {mapPriority(task.priority, t)}
{task.description &&

{task.description}

} @@ -155,11 +167,11 @@ export default function EventTasksPage() {

- Tasks aus Bibliothek hinzufuegen + {t('management.tasks.sections.library.title', 'Tasks aus Bibliothek hinzufügen')}

{availableTasks.length === 0 ? ( - + ) : ( availableTasks.map((task) => (
@@ -209,22 +221,22 @@ function TaskSkeleton() { ); } -function mapPriority(priority: TenantTask['priority']): string { +function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string { switch (priority) { case 'low': - return 'Niedrig'; + return translate('management.tasks.priorities.low', 'Niedrig'); case 'high': - return 'Hoch'; + return translate('management.tasks.priorities.high', 'Hoch'); case 'urgent': - return 'Dringend'; + return translate('management.tasks.priorities.urgent', 'Dringend'); default: - return 'Mittel'; + return translate('management.tasks.priorities.medium', 'Mittel'); } } -function renderName(name: TenantEvent['name']): string { +function renderName(name: TenantEvent['name'], translate: (key: string, defaultValue: string) => string): string { if (typeof name === 'string') { return name; } - return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event'; + return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? translate('management.members.events.untitled', 'Unbenanntes Event'); } diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index 822fe8f..993ee6d 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -1,4 +1,4 @@ -import { destroy } from '@/actions/App/Http/Controllers/Settings/ProfileController'; +import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import HeadingSmall from '@/components/heading-small'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index a8bbe3b..0c58fee 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -29,12 +29,12 @@ function TabLink({ } export default function BottomNav() { - const { slug } = useParams(); + const { token } = useParams(); const location = useLocation(); const { event } = useEventData(); - if (!slug) return null; // Only show bottom nav within event context - const base = `/e/${encodeURIComponent(slug)}`; + if (!token) return null; // Only show bottom nav within event context + const base = `/e/${encodeURIComponent(token)}`; const currentPath = location.pathname; const locale = event?.default_locale || 'de'; @@ -57,7 +57,7 @@ export default function BottomNav() { const t = translations[locale as keyof typeof translations] || translations.de; // Improved active state logic - const isHomeActive = currentPath === base || currentPath === `/${slug}`; + const isHomeActive = currentPath === base || currentPath === `/${token}`; const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`; const isAchievementsActive = currentPath.startsWith(`${base}/achievements`); const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`); diff --git a/resources/js/guest/components/EmotionPicker.tsx b/resources/js/guest/components/EmotionPicker.tsx index 63deb9d..a2da7ae 100644 --- a/resources/js/guest/components/EmotionPicker.tsx +++ b/resources/js/guest/components/EmotionPicker.tsx @@ -16,7 +16,8 @@ interface EmotionPickerProps { } export default function EmotionPicker({ onSelect }: EmotionPickerProps) { - const { slug } = useParams<{ slug: string }>(); + const { token: slug } = useParams<{ token: string }>(); + const eventKey = slug ?? ''; const navigate = useNavigate(); const [emotions, setEmotions] = useState([]); const [loading, setLoading] = useState(true); @@ -33,7 +34,7 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) { ]; useEffect(() => { - if (!slug) return; + if (!eventKey) return; async function fetchEmotions() { try { @@ -41,7 +42,7 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) { setError(null); // Try API first - const response = await fetch(`/api/v1/events/${slug}/emotions`); + const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/emotions`); if (response.ok) { const data = await response.json(); setEmotions(Array.isArray(data) ? data : fallbackEmotions); @@ -60,14 +61,15 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) { } fetchEmotions(); - }, [slug]); + }, [eventKey]); const handleEmotionSelect = (emotion: Emotion) => { if (onSelect) { onSelect(emotion); } else { // Default: Navigate to tasks with emotion filter - navigate(`/e/${slug}/tasks?emotion=${emotion.slug}`); + if (!eventKey) return; + navigate(`/e/${encodeURIComponent(eventKey)}/tasks?emotion=${emotion.slug}`); } }; @@ -139,11 +141,14 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
); -} \ 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
- + Alle ansehen →
@@ -97,7 +97,7 @@ export default function GalleryPreview({ slug }: Props) { )}
{items.map((p: any) => ( - +
); } - diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index cc0bf81..59fb1cb 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -29,8 +29,8 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st } const { event, loading: eventLoading, error: eventError } = useEventData(); - const stats = statsContext && statsContext.slug === slug ? statsContext : undefined; - const guestName = identity && identity.slug === slug && identity.hydrated && identity.name ? identity.name : null; + const stats = statsContext && statsContext.eventKey === slug ? statsContext : undefined; + const guestName = identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null; if (eventLoading) { return ( diff --git a/resources/js/guest/context/EventStatsContext.tsx b/resources/js/guest/context/EventStatsContext.tsx index dd76d06..3ae66ae 100644 --- a/resources/js/guest/context/EventStatsContext.tsx +++ b/resources/js/guest/context/EventStatsContext.tsx @@ -2,16 +2,17 @@ import React from 'react'; import { usePollStats } from '../polling/usePollStats'; type EventStatsContextValue = ReturnType & { + eventKey: string; slug: string; }; const EventStatsContext = React.createContext(undefined); -export function EventStatsProvider({ slug, children }: { slug: string; children: React.ReactNode }) { - const stats = usePollStats(slug); +export function EventStatsProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) { + const stats = usePollStats(eventKey); const value = React.useMemo( - () => ({ slug, ...stats }), - [slug, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading] + () => ({ eventKey, slug: eventKey, ...stats }), + [eventKey, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading] ); return {children}; } diff --git a/resources/js/guest/context/GuestIdentityContext.tsx b/resources/js/guest/context/GuestIdentityContext.tsx index e4c301e..238e463 100644 --- a/resources/js/guest/context/GuestIdentityContext.tsx +++ b/resources/js/guest/context/GuestIdentityContext.tsx @@ -1,7 +1,8 @@ import React from 'react'; type GuestIdentityContextValue = { - slug: string; + eventKey: string; + slug: string; // backward-compatible alias name: string; hydrated: boolean; setName: (nextName: string) => void; @@ -11,36 +12,36 @@ type GuestIdentityContextValue = { const GuestIdentityContext = React.createContext(undefined); -function storageKey(slug: string) { - return `guestName_${slug}`; +function storageKey(eventKey: string) { + return `guestName_${eventKey}`; } -export function readGuestName(slug: string) { - if (!slug || typeof window === 'undefined') { +export function readGuestName(eventKey: string) { + if (!eventKey || typeof window === 'undefined') { return ''; } try { - return window.localStorage.getItem(storageKey(slug)) ?? ''; + return window.localStorage.getItem(storageKey(eventKey)) ?? ''; } catch (error) { console.warn('Failed to read guest name', error); return ''; } } -export function GuestIdentityProvider({ slug, children }: { slug: string; children: React.ReactNode }) { +export function GuestIdentityProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) { const [name, setNameState] = React.useState(''); const [hydrated, setHydrated] = React.useState(false); const loadFromStorage = React.useCallback(() => { - if (!slug) { + if (!eventKey) { setHydrated(true); setNameState(''); return; } try { - const stored = window.localStorage.getItem(storageKey(slug)); + const stored = window.localStorage.getItem(storageKey(eventKey)); setNameState(stored ?? ''); } catch (error) { console.warn('Failed to read guest name from storage', error); @@ -48,7 +49,7 @@ export function GuestIdentityProvider({ slug, children }: { slug: string; childr } finally { setHydrated(true); } - }, [slug]); + }, [eventKey]); React.useEffect(() => { setHydrated(false); @@ -61,36 +62,37 @@ export function GuestIdentityProvider({ slug, children }: { slug: string; childr setNameState(trimmed); try { if (trimmed) { - window.localStorage.setItem(storageKey(slug), trimmed); + window.localStorage.setItem(storageKey(eventKey), trimmed); } else { - window.localStorage.removeItem(storageKey(slug)); + window.localStorage.removeItem(storageKey(eventKey)); } } catch (error) { console.warn('Failed to persist guest name', error); } }, - [slug] + [eventKey] ); const clearName = React.useCallback(() => { setNameState(''); try { - window.localStorage.removeItem(storageKey(slug)); + window.localStorage.removeItem(storageKey(eventKey)); } catch (error) { console.warn('Failed to clear guest name', error); } - }, [slug]); + }, [eventKey]); const value = React.useMemo( () => ({ - slug, + eventKey, + slug: eventKey, name, hydrated, setName: persistName, clearName, reload: loadFromStorage, }), - [slug, name, hydrated, persistName, clearName, loadFromStorage] + [eventKey, name, hydrated, persistName, clearName, loadFromStorage] ); return {children}; diff --git a/resources/js/guest/hooks/useEventData.ts b/resources/js/guest/hooks/useEventData.ts index 8deabee..60f6f63 100644 --- a/resources/js/guest/hooks/useEventData.ts +++ b/resources/js/guest/hooks/useEventData.ts @@ -3,14 +3,14 @@ import { useParams } from 'react-router-dom'; import { fetchEvent, EventData } from '../services/eventApi'; export function useEventData() { - const { slug } = useParams<{ slug: string }>(); + const { token } = useParams<{ token: string }>(); const [event, setEvent] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - if (!slug) { - setError('No event slug provided'); + if (!token) { + setError('No event token provided'); setLoading(false); return; } @@ -19,7 +19,7 @@ export function useEventData() { try { setLoading(true); setError(null); - const eventData = await fetchEvent(slug); + const eventData = await fetchEvent(token); setEvent(eventData); } catch (err) { console.error('Failed to load event:', err); @@ -30,11 +30,11 @@ export function useEventData() { }; loadEvent(); - }, [slug]); + }, [token]); return { event, loading, error, }; -} \ No newline at end of file +} diff --git a/resources/js/guest/hooks/useGuestTaskProgress.ts b/resources/js/guest/hooks/useGuestTaskProgress.ts index 02a1782..0631a34 100644 --- a/resources/js/guest/hooks/useGuestTaskProgress.ts +++ b/resources/js/guest/hooks/useGuestTaskProgress.ts @@ -1,7 +1,7 @@ import React from 'react'; -function storageKey(slug: string) { - return `guestTasks_${slug}`; +function storageKey(eventKey: string) { + return `guestTasks_${eventKey}`; } function parseStored(value: string | null) { @@ -20,18 +20,18 @@ function parseStored(value: string | null) { } } -export function useGuestTaskProgress(slug: string | undefined) { +export function useGuestTaskProgress(eventKey: string | undefined) { const [completed, setCompleted] = React.useState([]); const [hydrated, setHydrated] = React.useState(false); React.useEffect(() => { - if (!slug) { + if (!eventKey) { setCompleted([]); setHydrated(true); return; } try { - const stored = window.localStorage.getItem(storageKey(slug)); + const stored = window.localStorage.getItem(storageKey(eventKey)); setCompleted(parseStored(stored)); } catch (error) { console.warn('Failed to read task progress', error); @@ -39,24 +39,24 @@ export function useGuestTaskProgress(slug: string | undefined) { } finally { setHydrated(true); } - }, [slug]); + }, [eventKey]); const persist = React.useCallback( (next: number[]) => { - if (!slug) return; + if (!eventKey) return; setCompleted(next); try { - window.localStorage.setItem(storageKey(slug), JSON.stringify(next)); + window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next)); } catch (error) { console.warn('Failed to persist task progress', error); } }, - [slug] + [eventKey] ); const markCompleted = React.useCallback( (taskId: number) => { - if (!slug || !Number.isInteger(taskId)) { + if (!eventKey || !Number.isInteger(taskId)) { return; } setCompleted((prev) => { @@ -65,25 +65,25 @@ export function useGuestTaskProgress(slug: string | undefined) { } const next = [...prev, taskId]; try { - window.localStorage.setItem(storageKey(slug), JSON.stringify(next)); + window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next)); } catch (error) { console.warn('Failed to persist task progress', error); } return next; }); }, - [slug] + [eventKey] ); const clearProgress = React.useCallback(() => { - if (!slug) return; + if (!eventKey) return; setCompleted([]); try { - window.localStorage.removeItem(storageKey(slug)); + window.localStorage.removeItem(storageKey(eventKey)); } catch (error) { console.warn('Failed to clear task progress', error); } - }, [slug]); + }, [eventKey]); const isCompleted = React.useCallback( (taskId: number | null | undefined) => { diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx index 3218cbe..d7e0b8c 100644 --- a/resources/js/guest/main.tsx +++ b/resources/js/guest/main.tsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import { router } from './router'; import '../../css/app.css'; -import { initializeTheme } from '@/hooks/use-appearance.tsx'; +import { initializeTheme } from '@/hooks/use-appearance'; import { ToastProvider } from './components/ToastHost'; initializeTheme(); @@ -30,3 +30,4 @@ createRoot(rootEl).render( ); + diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx index 700e9c0..4592f3e 100644 --- a/resources/js/guest/pages/AchievementsPage.tsx +++ b/resources/js/guest/pages/AchievementsPage.tsx @@ -292,7 +292,7 @@ function PersonalActions({ slug }: { slug: string }) { } export default function AchievementsPage() { - const { slug } = useParams<{ slug: string }>(); + const { token: slug } = useParams<{ token: string }>(); const identity = useGuestIdentity(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 380d8a0..11e9505 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -12,8 +12,8 @@ import PhotoLightbox from './PhotoLightbox'; import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi'; export default function GalleryPage() { - const { slug } = useParams(); - const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug!); + const { token: slug } = useParams<{ token?: string }>(); + const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug ?? ''); const [filter, setFilter] = React.useState('latest'); const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState(null); const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); @@ -99,6 +99,10 @@ export default function GalleryPage() { } } + if (!slug) { + return

Event nicht gefunden.

; + } + if (eventLoading) { return

Lade Event-Info...

; } diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index 0cdd6ed..c159871 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -12,13 +12,13 @@ import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react'; export default function HomePage() { - const { slug } = useParams<{ slug: string }>(); + const { token } = useParams<{ token: string }>(); const { name, hydrated } = useGuestIdentity(); const stats = useEventStats(); const { event } = useEventData(); - const { completedCount } = useGuestTaskProgress(slug); + const { completedCount } = useGuestTaskProgress(token); - if (!slug) return null; + if (!token) return null; const displayName = hydrated && name ? name : 'Gast'; const latestUploadText = formatLatestUpload(stats.latestPhotoAt); @@ -125,7 +125,7 @@ export default function HomePage() { - +
); } diff --git a/resources/js/guest/pages/LandingPage.tsx b/resources/js/guest/pages/LandingPage.tsx index 0e74207..848d5e2 100644 --- a/resources/js/guest/pages/LandingPage.tsx +++ b/resources/js/guest/pages/LandingPage.tsx @@ -10,28 +10,58 @@ import { readGuestName } from '../context/GuestIdentityContext'; export default function LandingPage() { const nav = useNavigate(); - const [slug, setSlug] = useState(''); + const [eventCode, setEventCode] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [isScanning, setIsScanning] = useState(false); const [scanner, setScanner] = useState(null); - async function join(eventSlug?: string) { - const s = (eventSlug ?? slug).trim(); - if (!s) return; + function extractEventKey(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ''; + } + + try { + const url = new URL(trimmed); + const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token'); + if (inviteParam) { + return inviteParam; + } + const segments = url.pathname.split('/').filter(Boolean); + const eventIndex = segments.findIndex((segment) => segment === 'e'); + if (eventIndex >= 0 && segments.length > eventIndex + 1) { + return decodeURIComponent(segments[eventIndex + 1]); + } + if (segments.length > 0) { + return decodeURIComponent(segments[segments.length - 1]); + } + } catch { + // Not a URL, treat as raw code + } + + return trimmed; + } + + async function join(input?: string) { + const provided = input ?? eventCode; + const normalized = extractEventKey(provided); + if (!normalized) return; setLoading(true); setError(null); try { - const res = await fetch(`/api/v1/events/${encodeURIComponent(s)}`); + const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`); if (!res.ok) { setError('Event nicht gefunden oder geschlossen.'); return; } - const storedName = readGuestName(s); + const data = await res.json(); + const targetKey = data.join_token ?? data.slug ?? normalized; + const storedName = readGuestName(targetKey); if (!storedName) { - nav(`/setup/${encodeURIComponent(s)}`); + nav(`/setup/${encodeURIComponent(targetKey)}`); } else { - nav(`/e/${encodeURIComponent(s)}`); + nav(`/e/${encodeURIComponent(targetKey)}`); } } catch (e) { console.error('Join request failed', e); @@ -136,14 +166,14 @@ export default function LandingPage() {
setSlug(event.target.value)} + value={eventCode} + onChange={(event) => setEventCode(event.target.value)} placeholder="Event-Code eingeben" disabled={loading} /> - - - - - + + + {item.children.map((child) => ( + + + {child.label} + + + ))} + + + ) : ( + - - - - - {t('header.occasions.wedding', 'Hochzeit')} - - - - - {t('header.occasions.birthday', 'Geburtstag')} - - - - - {t('header.occasions.corporate', 'Firmenevent')} - - - - - + ) + ))} -
+
+
+ + + + + + + Menü + + + +
+
+ Darstellung + +
+
+ Sprache + +
+
+ {auth.user ? ( + <> + + + Profil + + + + + Bestellungen + + + + + ) : ( +
+ + + {t('header.login')} + + + + + {t('header.register')} + + +
+ )} +
+
+
+
+
); }; -export default Header; \ No newline at end of file +export default Header; diff --git a/resources/js/layouts/mainWebsite.tsx b/resources/js/layouts/mainWebsite.tsx index 7d6834e..e13f86e 100644 --- a/resources/js/layouts/mainWebsite.tsx +++ b/resources/js/layouts/mainWebsite.tsx @@ -59,7 +59,7 @@ const MarketingLayout: React.FC = ({ children, title }) => /> - +
@@ -77,4 +77,4 @@ const MarketingLayout: React.FC = ({ children, title }) => ); }; -export default MarketingLayout; \ No newline at end of file +export default MarketingLayout; diff --git a/resources/js/pages/Profile/Orders.tsx b/resources/js/pages/Profile/Orders.tsx index d97d58c..055329d 100644 --- a/resources/js/pages/Profile/Orders.tsx +++ b/resources/js/pages/Profile/Orders.tsx @@ -16,7 +16,8 @@ interface Purchase { } const ProfileOrders = () => { - const { purchases } = usePage().props as any; + const page = usePage<{ purchases?: Purchase[] }>(); + const purchases = page.props.purchases ?? []; return (
@@ -58,4 +59,4 @@ const ProfileOrders = () => { ); }; -export default ProfileOrders; \ No newline at end of file +export default ProfileOrders; diff --git a/resources/js/pages/auth/RegisterForm.tsx b/resources/js/pages/auth/RegisterForm.tsx index 99b3179..75f691d 100644 --- a/resources/js/pages/auth/RegisterForm.tsx +++ b/resources/js/pages/auth/RegisterForm.tsx @@ -20,6 +20,19 @@ interface RegisterFormProps { locale?: string; } +type RegisterFormFields = { + username: string; + email: string; + password: string; + password_confirmation: string; + first_name: string; + last_name: string; + address: string; + phone: string; + privacy_consent: boolean; + package_id: number | null; +}; + export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }: RegisterFormProps) { const [privacyOpen, setPrivacyOpen] = useState(false); const [hasTriedSubmit, setHasTriedSubmit] = useState(false); @@ -28,7 +41,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale const page = usePage<{ errors: Record; locale?: string; auth?: { user?: any | null } }>(); const resolvedLocale = locale ?? page.props.locale ?? 'de'; - const { data, setData, errors, clearErrors, reset, setError } = useForm({ + const { data, setData, errors, clearErrors, reset, setError } = useForm({ username: '', email: '', password: '', @@ -91,10 +104,12 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale const json = await response.json(); const fieldErrors = json?.errors ?? {}; Object.entries(fieldErrors).forEach(([key, message]) => { - if (Array.isArray(message) && message.length > 0) { - setError(key, message[0] as string); - } else if (typeof message === 'string') { - setError(key, message); + if (key in data) { + const fieldKey = key as keyof RegisterFormFields; + const firstMessage = Array.isArray(message) ? (message[0] as string | undefined) : typeof message === 'string' ? message : undefined; + if (firstMessage) { + setError(fieldKey, firstMessage); + } } }); toast.error(t('register.validation_failed', 'Bitte pruefen Sie Ihre Eingaben.')); @@ -409,4 +424,3 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale - diff --git a/resources/js/pages/auth/forgot-password.tsx b/resources/js/pages/auth/forgot-password.tsx index 6c0c09e..48cdce8 100644 --- a/resources/js/pages/auth/forgot-password.tsx +++ b/resources/js/pages/auth/forgot-password.tsx @@ -3,6 +3,7 @@ import { store } from '@/actions/App/Http/Controllers/Auth/PasswordResetLinkCont import { login } from '@/routes'; import { Form, Head } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import InputError from '@/components/input-error'; import TextLink from '@/components/text-link'; diff --git a/resources/js/pages/marketing/CheckoutWizardPage.tsx b/resources/js/pages/marketing/CheckoutWizardPage.tsx index d1ee27c..74e6346 100644 --- a/resources/js/pages/marketing/CheckoutWizardPage.tsx +++ b/resources/js/pages/marketing/CheckoutWizardPage.tsx @@ -10,6 +10,7 @@ interface CheckoutWizardPageProps { package: CheckoutPackage; packageOptions: CheckoutPackage[]; stripePublishableKey: string; + paypalClientId: string; privacyHtml: string; } @@ -17,6 +18,7 @@ export default function CheckoutWizardPage({ package: initialPackage, packageOptions, stripePublishableKey, + paypalClientId, privacyHtml, }: CheckoutWizardPageProps) { const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>(); @@ -57,6 +59,7 @@ export default function CheckoutWizardPage({ initialPackage={initialPackage} packageOptions={dedupedOptions} stripePublishableKey={stripePublishableKey} + paypalClientId={paypalClientId} privacyHtml={privacyHtml} initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null} /> diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index 534c24b..de415bd 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -14,6 +14,7 @@ interface CheckoutWizardProps { initialPackage: CheckoutPackage; packageOptions: CheckoutPackage[]; stripePublishableKey: string; + paypalClientId: string; privacyHtml: string; initialAuthUser?: { id: number; @@ -50,7 +51,7 @@ const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: st detailsKey: 'checkout.confirmation_step.description' }, ]; -const WizardBody: React.FC<{ stripePublishableKey: string; privacyHtml: string }> = ({ stripePublishableKey, privacyHtml }) => { +const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => { const { t } = useTranslation('marketing'); const { currentStep, nextStep, previousStep } = useCheckoutWizard(); @@ -82,7 +83,9 @@ const WizardBody: React.FC<{ stripePublishableKey: string; privacyHtml: string }
{currentStep === "package" && } {currentStep === "auth" && } - {currentStep === "payment" && } + {currentStep === "payment" && ( + + )} {currentStep === "confirmation" && }
@@ -102,6 +105,7 @@ export const CheckoutWizard: React.FC = ({ initialPackage, packageOptions, stripePublishableKey, + paypalClientId, privacyHtml, initialAuthUser, initialStep, @@ -115,7 +119,7 @@ export const CheckoutWizard: React.FC = ({ initialAuthUser={initialAuthUser ?? undefined} initialIsAuthenticated={Boolean(initialAuthUser)} > - + ); }; diff --git a/resources/js/pages/settings/password.tsx b/resources/js/pages/settings/password.tsx index a637041..6863364 100644 --- a/resources/js/pages/settings/password.tsx +++ b/resources/js/pages/settings/password.tsx @@ -1,4 +1,4 @@ -import { store } from '@/actions/App/Http/Controllers/Settings/PasswordController'; +import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController'; import InputError from '@/components/input-error'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; @@ -35,7 +35,7 @@ export default function Password() {
name('api.v1.')->group(function () { Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update'); }); - Route::prefix('events/{event:slug}')->group(function () { + Route::prefix('events/{event:slug}')->scopeBindings()->group(function () { Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats'); Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle'); Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites'); + Route::prefix('join-tokens')->group(function () { + Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index'); + Route::post('/', [EventJoinTokenController::class, 'store'])->name('tenant.events.join-tokens.store'); + Route::delete('{joinToken}', [EventJoinTokenController::class, 'destroy']) + ->whereNumber('joinToken') + ->name('tenant.events.join-tokens.destroy'); + }); + Route::get('photos', [PhotoController::class, 'index'])->name('tenant.events.photos.index'); Route::post('photos', [PhotoController::class, 'store'])->name('tenant.events.photos.store'); Route::get('photos/{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show');