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.
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
73
app/Http/Controllers/Api/Tenant/EventJoinTokenController.php
Normal file
73
app/Http/Controllers/Api/Tenant/EventJoinTokenController.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Tenant\EventJoinTokenResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class EventJoinTokenController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EventJoinTokenService $joinTokenService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
29
app/Http/Resources/Tenant/EventJoinTokenResource.php
Normal file
29
app/Http/Resources/Tenant/EventJoinTokenResource.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventJoinTokenResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
58
app/Models/EventJoinToken.php
Normal file
58
app/Models/EventJoinToken.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventJoinToken extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'token',
|
||||
'label',
|
||||
'usage_limit',
|
||||
'usage_count',
|
||||
'expires_at',
|
||||
'revoked_at',
|
||||
'created_by',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => '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;
|
||||
}
|
||||
}
|
||||
85
app/Services/EventJoinTokenService.php
Normal file
85
app/Services/EventJoinTokenService.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventJoinTokenService
|
||||
{
|
||||
public function createToken(Event $event, array $attributes = []): EventJoinToken
|
||||
{
|
||||
return DB::transaction(function () use ($event, $attributes) {
|
||||
$tokenValue = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_join_tokens', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
34
docs/todo/event-join-token-hardening.md
Normal file
34
docs/todo/event-join-token-hardening.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function eventEndpoint(slug: string): string {
|
||||
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
@@ -337,9 +367,40 @@ export async function getEventStats(slug: string): Promise<EventStats> {
|
||||
};
|
||||
}
|
||||
|
||||
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<EventJoinToken[]> {
|
||||
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<EventJoinToken> {
|
||||
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<EventJoinToken> {
|
||||
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 = {
|
||||
|
||||
@@ -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
|
||||
<header className="border-b border-brand-rose-soft bg-brand-card/90 shadow-brand-primary backdrop-blur-md">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">Fotospiel Tenant Admin</p>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">{t('app.brand')}</p>
|
||||
<h1 className="font-display text-3xl font-semibold text-brand-slate">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm font-sans-marketing text-brand-navy/75">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mx-auto flex w-full max-w-6xl gap-3 px-6 pb-4 text-sm font-medium text-brand-navy/80">
|
||||
{navItems.map((item) => (
|
||||
@@ -58,7 +65,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
109
resources/js/admin/components/LanguageSwitcher.tsx
Normal file
109
resources/js/admin/components/LanguageSwitcher.tsx
Normal file
@@ -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<HTMLMetaElement>('meta[name=\"csrf-token\"]')?.content ?? '';
|
||||
}
|
||||
|
||||
async function persistLocale(locale: SupportedLocale): Promise<void> {
|
||||
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<SupportedLocale | null>(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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
|
||||
aria-label={t('app.languageSwitch')}
|
||||
>
|
||||
<Languages className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('app.languageSwitch')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{SUPPORTED_LANGUAGES.map(({ code, labelKey }) => {
|
||||
const isActive = currentLocale === code;
|
||||
const isPending = pendingLocale === code;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
changeLanguage(code);
|
||||
}}
|
||||
className="flex items-center justify-between gap-3"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span>{t(labelKey)}</span>
|
||||
{(isActive || isPending) && <Check className="h-4 w-4 text-brand-rose" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
61
resources/js/admin/i18n/index.ts
Normal file
61
resources/js/admin/i18n/index.ts
Normal file
@@ -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;
|
||||
21
resources/js/admin/i18n/locales/de/common.json
Normal file
21
resources/js/admin/i18n/locales/de/common.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
72
resources/js/admin/i18n/locales/de/dashboard.json
Normal file
72
resources/js/admin/i18n/locales/de/dashboard.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
150
resources/js/admin/i18n/locales/de/management.json
Normal file
150
resources/js/admin/i18n/locales/de/management.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
268
resources/js/admin/i18n/locales/de/onboarding.json
Normal file
268
resources/js/admin/i18n/locales/de/onboarding.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
21
resources/js/admin/i18n/locales/en/common.json
Normal file
21
resources/js/admin/i18n/locales/en/common.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
72
resources/js/admin/i18n/locales/en/dashboard.json
Normal file
72
resources/js/admin/i18n/locales/en/dashboard.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
149
resources/js/admin/i18n/locales/en/management.json
Normal file
149
resources/js/admin/i18n/locales/en/management.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
264
resources/js/admin/i18n/locales/en/onboarding.json
Normal file
264
resources/js/admin/i18n/locales/en/onboarding.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{headerAction && <div className="flex shrink-0 items-center">{headerAction}</div>}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{headerAction}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col gap-8">
|
||||
@@ -64,4 +67,4 @@ export function TenantWelcomeLayout({
|
||||
);
|
||||
}
|
||||
|
||||
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';
|
||||
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';
|
||||
|
||||
@@ -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 (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Schritt 4"
|
||||
title="Bereite dein erstes Event vor"
|
||||
subtitle="F<>lle wenige Details aus, lade Co-Hosts ein und <20>ffne deine G<>stegalerie f<>r das gro<72>e Ereignis."
|
||||
eyebrow={t("eventSetup.layout.eyebrow")}
|
||||
title={t("eventSetup.layout.title")}
|
||||
subtitle={t("eventSetup.layout.subtitle")}
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={4}
|
||||
totalSteps={4}
|
||||
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."
|
||||
title={t("eventSetup.step.title")}
|
||||
description={t("eventSetup.step.description")}
|
||||
icon={ClipboardCheck}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
id: 'story',
|
||||
title: 'Story & Stimmung',
|
||||
copy: 'W<>hle Bildsprache, Farben und Emotionskarten f<>r 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 G<>stegalerie 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() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-start gap-3 rounded-3xl border border-brand-rose-soft bg-brand-sky-soft/40 p-6 text-brand-navy">
|
||||
<h4 className="text-lg font-semibold text-brand-rose">Bereit f<EFBFBD>r dein erstes Event?</h4>
|
||||
<p className="text-sm text-brand-navy/80">
|
||||
Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die
|
||||
G<EFBFBD>stegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zur<EFBFBD>ckkehren.
|
||||
</p>
|
||||
<h4 className="text-lg font-semibold text-brand-rose">{t("eventSetup.cta.heading")}</h4>
|
||||
<p className="text-sm text-brand-navy/80">{t("eventSetup.cta.description")}</p>
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-2 rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => {
|
||||
markStep({ lastStep: 'event-create-intent' });
|
||||
markStep({ lastStep: "event-create-intent" });
|
||||
navigate(ADMIN_EVENT_CREATE_PATH);
|
||||
}}
|
||||
>
|
||||
Event erstellen
|
||||
{t("eventSetup.cta.button")}
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -88,29 +88,29 @@ export default function WelcomeEventSetupPage() {
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'back',
|
||||
label: 'Noch einmal Pakete pr<70>fen',
|
||||
description: 'Vergleiche Preise oder aktualisiere dein derzeitiges Paket.',
|
||||
buttonLabel: 'Zu Paketen',
|
||||
id: "back",
|
||||
label: t("eventSetup.actions.back.label"),
|
||||
description: t("eventSetup.actions.back.description"),
|
||||
buttonLabel: t("eventSetup.actions.back.button"),
|
||||
onClick: () => navigate(-1),
|
||||
variant: 'secondary',
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Zum Dashboard',
|
||||
description: 'Springe ins Management, um bestehende Events zu bearbeiten.',
|
||||
buttonLabel: 'Dashboard <20>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: 'Event<6E>bersicht',
|
||||
description: 'Behalte den <20>berblick <20>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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<TenantWelcomeLayout
|
||||
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."
|
||||
eyebrow={t("layout.eyebrow")}
|
||||
title={t("layout.title")}
|
||||
subtitle={t("layout.subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<span className="text-brand-navy/80">Schon vertraut mit Fotospiel?</span>
|
||||
<span className="text-brand-navy/80">{t("layout.alreadyFamiliar")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-brand-rose hover:text-[var(--brand-rose-strong)]"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
>
|
||||
Direkt zum Dashboard
|
||||
{t("layout.jumpToDashboard")}
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<WelcomeHero
|
||||
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."
|
||||
eyebrow={t("hero.eyebrow")}
|
||||
title={t("hero.title")}
|
||||
scriptTitle={t("hero.scriptTitle")}
|
||||
description={t("hero.description")}
|
||||
actions={[
|
||||
{
|
||||
label: "Pakete entdecken",
|
||||
buttonLabel: "Pakete entdecken",
|
||||
label: t("hero.primary.label"),
|
||||
onClick: () => 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",
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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<typeof useTranslation>["t"];
|
||||
};
|
||||
|
||||
function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeCheckoutProps) {
|
||||
type PayPalCheckoutProps = {
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
t: ReturnType<typeof useTranslation>["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<typeof useTranslation>["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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-brand-slate">Kartenzahlung</p>
|
||||
<p className="text-sm font-medium text-brand-slate">{t("summary.stripe.heading")}</p>
|
||||
<PaymentElement id="payment-element" />
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Zahlung fehlgeschlagen</AlertTitle>
|
||||
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -114,110 +168,94 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Zahlung wird bestätigt ...
|
||||
{t("summary.stripe.submitting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="mr-2 size-4" />
|
||||
Jetzt bezahlen
|
||||
{t("summary.stripe.submit")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-brand-navy/70">
|
||||
Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.
|
||||
</p>
|
||||
<input type="hidden" value={clientSecret} />
|
||||
<p className="text-xs text-brand-navy/70">{t("summary.stripe.hint")}</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
|
||||
<p className="text-sm font-medium text-brand-slate">PayPal</p>
|
||||
<p className="text-sm font-medium text-brand-slate">{t("summary.paypal.heading")}</p>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>PayPal-Fehler</AlertTitle>
|
||||
<AlertTitle>{t("summary.paypal.errorTitle")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<PayPalButtons
|
||||
style={{ layout: 'vertical' }}
|
||||
style={{ layout: "vertical" }}
|
||||
forceReRender={[packageId, currency]}
|
||||
createOrder={async () => 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"}
|
||||
/>
|
||||
<p className="text-xs text-brand-navy/70">
|
||||
PayPal leitet dich ggf. kurz weiter, um die Zahlung zu bestätigen. Anschließend wirst du automatisch zum Setup
|
||||
zurückgebracht.
|
||||
</p>
|
||||
<p className="text-xs text-brand-navy/70">{t("summary.paypal.hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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<string | null>(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<string | null>(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 (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Schritt 3"
|
||||
title="Bestellübersicht"
|
||||
subtitle="Prüfe Paket, Preis und Abrechnung bevor du zum Event-Setup wechselst."
|
||||
eyebrow={t("summary.layout.eyebrow")}
|
||||
title={t("summary.layout.title")}
|
||||
subtitle={t("summary.layout.subtitle")}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
@@ -321,50 +383,48 @@ export default function WelcomeOrderSummaryPage() {
|
||||
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zurück zur Paketauswahl
|
||||
{t("summary.footer.back")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={3}
|
||||
totalSteps={4}
|
||||
title="Deine Auswahl im Überblick"
|
||||
description="Du kannst sofort an die Abrechnung übergeben oder das Setup fortsetzen und später bezahlen."
|
||||
title={t("summary.step.title")}
|
||||
description={t("summary.step.description")}
|
||||
icon={Receipt}
|
||||
>
|
||||
{packagesState.status === 'loading' && (
|
||||
{packagesState.status === "loading" && (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-slate-50 p-6 text-sm text-brand-navy/80">
|
||||
<CreditCard className="size-5 text-brand-rose" />
|
||||
Wir prüfen verfügbare Pakete …
|
||||
{t("summary.state.loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'error' && (
|
||||
{packagesState.status === "error" && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>Paketdaten derzeit nicht verfügbar</AlertTitle>
|
||||
<AlertTitle>{t("summary.state.errorTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{packagesState.message} Du kannst das Event trotzdem vorbereiten und später zurückkehren.
|
||||
{packagesState.message ?? t("summary.state.errorDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && !packageDetails && (
|
||||
{packagesState.status === "success" && !packageDetails && (
|
||||
<Alert>
|
||||
<AlertTitle>Keine Paketauswahl gefunden</AlertTitle>
|
||||
<AlertDescription>
|
||||
Bitte wähle zuerst ein Paket aus oder aktualisiere die Seite, falls sich die Daten geändert haben.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("summary.state.missingTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.state.missingDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && packageDetails && (
|
||||
{packagesState.status === "success" && packageDetails && (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-3xl border border-brand-rose-soft bg-brand-card p-6 shadow-md shadow-rose-100/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">
|
||||
{isSubscription ? 'Abo' : 'Credit-Paket'}
|
||||
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
|
||||
</p>
|
||||
<h3 className="text-2xl font-semibold text-brand-slate">{packageDetails.name}</h3>
|
||||
</div>
|
||||
@@ -376,59 +436,59 @@ export default function WelcomeOrderSummaryPage() {
|
||||
</div>
|
||||
<dl className="mt-6 grid gap-3 text-sm text-brand-navy/80 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Fotos & Galerie</dt>
|
||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.photosTitle")}</dt>
|
||||
<dd>
|
||||
{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")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Gäste & Team</dt>
|
||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.guestsTitle")}</dt>
|
||||
<dd>
|
||||
{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")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Highlights</dt>
|
||||
<dd>
|
||||
{Object.entries(packageDetails.features ?? {})
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([feature]) => feature.replace(/_/g, ' '))
|
||||
.join(', ') || 'Standard'}
|
||||
</dd>
|
||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.featuresTitle")}</dt>
|
||||
<dd>{featuresList.length ? featuresList.join(", ") : t("summary.details.section.featuresNone")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Status</dt>
|
||||
<dd>{activePackage ? 'Bereits gebucht' : 'Noch nicht gebucht'}</dd>
|
||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.statusTitle")}</dt>
|
||||
<dd>{activePackage ? t("summary.details.section.statusActive") : t("summary.details.section.statusInactive")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{detailBadges.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-wide text-brand-rose">
|
||||
{detailBadges.map((badge) => (
|
||||
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!activePackage && (
|
||||
<Alert className="rounded-2xl border-brand-rose-soft bg-brand-rose-soft/60 text-rose-700">
|
||||
<ShieldCheck className="size-4" />
|
||||
<AlertTitle>Abrechnung steht noch aus</AlertTitle>
|
||||
<AlertDescription>
|
||||
Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("summary.status.pendingTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.status.pendingDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packageDetails.price === 0 && (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-6">
|
||||
<p className="text-sm text-emerald-700">
|
||||
Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup
|
||||
weitermachen.
|
||||
</p>
|
||||
{freeAssignStatus === 'success' ? (
|
||||
<p className="text-sm text-emerald-700">{t("summary.free.description")}</p>
|
||||
{freeAssignStatus === "success" ? (
|
||||
<Alert className="mt-4 border-emerald-200 bg-white text-emerald-600">
|
||||
<AlertTitle>Gratis-Paket aktiviert</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("summary.free.successTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.free.successDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Button
|
||||
@@ -436,37 +496,37 @@ export default function WelcomeOrderSummaryPage() {
|
||||
if (!packageDetails) {
|
||||
return;
|
||||
}
|
||||
setFreeAssignStatus('loading');
|
||||
setFreeAssignStatus("loading");
|
||||
setFreeAssignError(null);
|
||||
try {
|
||||
await assignFreeTenantPackage(packageDetails.id);
|
||||
setFreeAssignStatus('success');
|
||||
setFreeAssignStatus("success");
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
} catch (error) {
|
||||
console.error('[Onboarding] Free package assignment failed', error);
|
||||
setFreeAssignStatus('error');
|
||||
console.error("[Onboarding] Free package assignment failed", error);
|
||||
setFreeAssignStatus("error");
|
||||
setFreeAssignError(
|
||||
error instanceof Error ? error.message : 'Kostenloses Paket konnte nicht aktiviert werden.'
|
||||
error instanceof Error ? error.message : t("summary.free.errorMessage")
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={freeAssignStatus === 'loading'}
|
||||
disabled={freeAssignStatus === "loading"}
|
||||
className="mt-4 rounded-full bg-brand-teal text-white shadow-md shadow-emerald-300/40 hover:opacity-90"
|
||||
>
|
||||
{freeAssignStatus === 'loading' ? (
|
||||
{freeAssignStatus === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Aktivierung läuft ...
|
||||
{t("summary.free.progress")}
|
||||
</>
|
||||
) : (
|
||||
'Gratis-Paket aktivieren'
|
||||
t("summary.free.activate")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{freeAssignStatus === 'error' && freeAssignError && (
|
||||
{freeAssignStatus === "error" && freeAssignError && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<AlertTitle>Aktivierung fehlgeschlagen</AlertTitle>
|
||||
<AlertTitle>{t("summary.free.failureTitle")}</AlertTitle>
|
||||
<AlertDescription>{freeAssignError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -476,20 +536,20 @@ export default function WelcomeOrderSummaryPage() {
|
||||
{requiresPayment && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">Kartenzahlung (Stripe)</h4>
|
||||
{intentStatus === 'loading' && (
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.stripe.sectionTitle")}</h4>
|
||||
{intentStatus === "loading" && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 text-sm text-brand-navy/80">
|
||||
<Loader2 className="size-4 animate-spin text-brand-rose" />
|
||||
Zahlungsdetails werden geladen …
|
||||
{t("summary.stripe.loading")}
|
||||
</div>
|
||||
)}
|
||||
{intentStatus === 'error' && (
|
||||
{intentStatus === "error" && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Stripe nicht verfügbar</AlertTitle>
|
||||
<AlertDescription>{intentError}</AlertDescription>
|
||||
<AlertTitle>{t("summary.stripe.unavailableTitle")}</AlertTitle>
|
||||
<AlertDescription>{intentError ?? t("summary.stripe.unavailableDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{intentStatus === 'ready' && clientSecret && stripePromise && (
|
||||
{intentStatus === "ready" && clientSecret && stripePromise && (
|
||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||
<StripeCheckoutForm
|
||||
clientSecret={clientSecret}
|
||||
@@ -498,6 +558,7 @@ export default function WelcomeOrderSummaryPage() {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
@@ -505,9 +566,14 @@ export default function WelcomeOrderSummaryPage() {
|
||||
|
||||
{paypalClientId ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">PayPal</h4>
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.paypal.sectionTitle")}</h4>
|
||||
<PayPalScriptProvider
|
||||
options={{ 'client-id': paypalClientId, currency: 'EUR', intent: 'CAPTURE' }}
|
||||
options={{
|
||||
clientId: paypalClientId,
|
||||
"client-id": paypalClientId,
|
||||
currency: "EUR",
|
||||
intent: "CAPTURE",
|
||||
}}
|
||||
>
|
||||
<PayPalCheckout
|
||||
packageId={packageDetails.id}
|
||||
@@ -515,26 +581,25 @@ export default function WelcomeOrderSummaryPage() {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
</div>
|
||||
) : (
|
||||
<Alert variant="default" className="border-amber-200 bg-amber-50 text-amber-700">
|
||||
<AlertTitle>PayPal nicht konfiguriert</AlertTitle>
|
||||
<AlertDescription>
|
||||
Hinterlege `VITE_PAYPAL_CLIENT_ID`, damit Gastgeber optional mit PayPal bezahlen können.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("summary.paypal.notConfiguredTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.paypal.notConfiguredDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-brand-card p-6 shadow-inner shadow-rose-100/40">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">Nächste Schritte</h4>
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.nextStepsTitle")}</h4>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm text-brand-navy/80">
|
||||
<li>Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.</li>
|
||||
<li>Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.</li>
|
||||
<li>Vor dem Go-Live Credits prüfen und Gäste-Link teilen.</li>
|
||||
{nextSteps.map((step, index) => (
|
||||
<li key={`${step}-${index}`}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,21 +609,21 @@ export default function WelcomeOrderSummaryPage() {
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'checkout',
|
||||
label: 'Abrechnung starten',
|
||||
description: 'Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).',
|
||||
buttonLabel: 'Zu Billing & Zahlung',
|
||||
id: "checkout",
|
||||
label: t("summary.cta.billing.label"),
|
||||
description: t("summary.cta.billing.description"),
|
||||
buttonLabel: t("summary.cta.billing.button"),
|
||||
href: ADMIN_BILLING_PATH,
|
||||
icon: CreditCard,
|
||||
variant: 'secondary',
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
id: 'continue-to-setup',
|
||||
label: 'Mit Event-Setup fortfahren',
|
||||
description: 'Du kannst später jederzeit zur Abrechnung zurückkehren.',
|
||||
buttonLabel: 'Weiter zum Setup',
|
||||
id: "continue-to-setup",
|
||||
label: t("summary.cta.setup.label"),
|
||||
description: t("summary.cta.setup.description"),
|
||||
buttonLabel: t("summary.cta.setup.button"),
|
||||
onClick: () => {
|
||||
markStep({ lastStep: 'event-setup' });
|
||||
markStep({ lastStep: "event-setup" });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH);
|
||||
},
|
||||
icon: ArrowRight,
|
||||
@@ -568,12 +633,3 @@ export default function WelcomeOrderSummaryPage() {
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<typeof useTranslation>["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 (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
|
||||
<Loader2 className="size-5 animate-spin text-brand-rose" />
|
||||
{t("packages.state.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (packagesState.status === "error") {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>{t("packages.state.errorTitle")}</AlertTitle>
|
||||
<AlertDescription>{packagesState.message ?? t("packages.state.errorDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (packagesState.status === "success" && packagesState.catalog.length === 0) {
|
||||
return (
|
||||
<Alert variant="default" className="border-brand-rose-soft bg-brand-card/60 text-brand-navy">
|
||||
<AlertTitle>{t("packages.state.emptyTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("packages.state.emptyDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (packagesState.status !== "success") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{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 (
|
||||
<div
|
||||
key={pkg.id}
|
||||
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
|
||||
{t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
|
||||
</div>
|
||||
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
|
||||
</div>
|
||||
<p className="text-sm text-brand-navy/80">
|
||||
{pkg.max_photos
|
||||
? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos })
|
||||
: t("packages.card.description")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
|
||||
{badges.map((badge) => (
|
||||
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
{featureLabels.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">
|
||||
{t("packages.card.active")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => handleSelectPackage(pkg)}
|
||||
>
|
||||
{t("packages.card.select")}
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
{purchased && (
|
||||
<p className="text-xs text-brand-rose">
|
||||
{t("packages.card.purchased", {
|
||||
date: purchased.purchased_at
|
||||
? dateFormatter.format(new Date(purchased.purchased_at))
|
||||
: t("packages.card.purchasedUnknown"),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Schritt 2"
|
||||
title="W<>hle dein Eventpaket"
|
||||
subtitle="Fotospiel unterst<73>tzt flexible Preismodelle: einmalige Credits oder Abos, die mehrere Events abdecken."
|
||||
eyebrow={t("packages.layout.eyebrow")}
|
||||
title={t("packages.layout.title")}
|
||||
subtitle={t("packages.layout.subtitle")}
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={2}
|
||||
totalSteps={4}
|
||||
title="Aktiviere die passenden Credits"
|
||||
description="Sichere dir Kapazit<69>t f<>r dein n<>chstes Event. Du kannst jederzeit upgraden <20> bezahle nur, was du wirklich brauchst."
|
||||
title={t("packages.step.title")}
|
||||
description={t("packages.step.description")}
|
||||
icon={CreditCard}
|
||||
>
|
||||
{packagesState.status === 'loading' && (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
|
||||
<Loader2 className="size-5 animate-spin text-brand-rose" />
|
||||
Pakete werden geladen <EFBFBD>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||
<AlertDescription>{packagesState.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{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 (
|
||||
<div
|
||||
key={pkg.id}
|
||||
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
|
||||
{isSubscription ? 'Abo' : 'Credit-Paket'}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
|
||||
</div>
|
||||
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
|
||||
</div>
|
||||
<p className="text-sm text-brand-navy/80">
|
||||
{pkg.max_photos
|
||||
? Bis zu Fotos inklusive <EFBFBD> perfekt f<EFBFBD>r lebendige Reportagen.
|
||||
: 'Sofort einsatzbereit f<>r dein n<>chstes Event.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
|
||||
{pkg.max_guests && (
|
||||
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.max_guests} G<EFBFBD>ste}</span>
|
||||
)}
|
||||
{pkg.gallery_days && (
|
||||
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.gallery_days} Tage Galerie}</span>
|
||||
)}
|
||||
{featureLabels.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">Aktives Paket</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => handleSelectPackage(pkg)}
|
||||
>
|
||||
Paket w<EFBFBD>hlen
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
{purchased && (
|
||||
<p className="text-xs text-brand-rose">
|
||||
Bereits gekauft am{' '}
|
||||
{purchased.purchased_at
|
||||
? new Date(purchased.purchased_at).toLocaleDateString('de-DE')
|
||||
: 'unbekanntem Datum'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{renderPackageList()}
|
||||
</WelcomeStepCard>
|
||||
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'skip-to-checkout',
|
||||
label: 'Direkt zum Billing',
|
||||
description:
|
||||
'Falls du schon wei<65>t, welches Paket du brauchst, gelangst du hier zum bekannten Abrechnungsbereich.',
|
||||
buttonLabel: 'Billing <20>ffnen',
|
||||
id: "skip-to-billing",
|
||||
label: t("packages.cta.billing.label"),
|
||||
description: t("packages.cta.billing.description"),
|
||||
buttonLabel: t("packages.cta.billing.button"),
|
||||
href: ADMIN_BILLING_PATH,
|
||||
icon: ShoppingBag,
|
||||
variant: 'secondary',
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
id: 'continue',
|
||||
label: 'Bestell<6C>bersicht anzeigen',
|
||||
description: 'Pr<50>fe Paketdetails und entscheide, ob du direkt zahlen oder sp<73>ter fortfahren m<>chtest.',
|
||||
buttonLabel: 'Weiter zur <20>bersicht',
|
||||
id: "continue",
|
||||
label: t("packages.cta.summary.label"),
|
||||
description: t("packages.cta.summary.description"),
|
||||
buttonLabel: t("packages.cta.summary.button"),
|
||||
onClick: () => navigate(ADMIN_WELCOME_SUMMARY_PATH),
|
||||
icon: ArrowRight,
|
||||
},
|
||||
@@ -197,4 +259,4 @@ export default function WelcomePackagesPage() {
|
||||
/>
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number>(0);
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
@@ -32,6 +39,51 @@ export default function BillingPage() {
|
||||
const [error, setError] = React.useState<string | null>(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 = (
|
||||
<Button variant="outline" onClick={() => void loadAll()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
Aktualisieren
|
||||
{t('management.billing.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Billing und Credits"
|
||||
subtitle="Verwalte Guthaben, Pakete und Abrechnungen."
|
||||
title={t('management.billing.title', 'Billing und Credits')}
|
||||
subtitle={t('management.billing.subtitle', 'Verwalte Guthaben, Pakete und Abrechnungen.')}
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -123,10 +175,10 @@ export default function BillingPage() {
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<CreditCard className="h-5 w-5 text-pink-500" />
|
||||
Credits und Status
|
||||
{t('management.billing.sections.overview.title', 'Credits und Status')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Dein aktuelles Guthaben und das aktive Reseller Paket.
|
||||
{t('management.billing.sections.overview.description', 'Dein aktuelles Guthaben und das aktive Reseller Paket.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
|
||||
@@ -134,24 +186,30 @@ export default function BillingPage() {
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCard label="Verfuegbare Credits" value={balance} tone="pink" />
|
||||
<InfoCard
|
||||
label="Genutzte Events"
|
||||
value={activePackage?.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={`Verfuegbar: ${activePackage?.remaining_events ?? 0}`}
|
||||
label={t('management.billing.sections.overview.cards.balance.label', 'Verfügbare Credits')}
|
||||
value={balance}
|
||||
tone="pink"
|
||||
/>
|
||||
<InfoCard
|
||||
label="Preis (netto)"
|
||||
value={formatCurrency(activePackage?.price)}
|
||||
label={t('management.billing.sections.overview.cards.used.label', 'Genutzte Events')}
|
||||
value={activePackage?.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={t('management.billing.sections.overview.cards.used.helper', {
|
||||
count: activePackage?.remaining_events ?? 0,
|
||||
})}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.price.label', 'Preis (netto)')}
|
||||
value={formatCurrency(activePackage?.price ?? null, activePackage?.currency ?? 'EUR')}
|
||||
tone="sky"
|
||||
helper={activePackage?.currency ?? 'EUR'}
|
||||
/>
|
||||
<InfoCard
|
||||
label="Ablauf"
|
||||
label={t('management.billing.sections.overview.cards.expires.label', 'Ablauf')}
|
||||
value={formatDate(activePackage?.expires_at)}
|
||||
tone="emerald"
|
||||
helper="Automatisch verlaengern falls aktiv"
|
||||
helper={t('management.billing.sections.overview.cards.expires.helper', 'Automatisch verlängern, falls aktiv')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -160,18 +218,25 @@ export default function BillingPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
Paket Historie
|
||||
{t('management.billing.packages.title', 'Paket Historie')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Uebersicht ueber aktive und vergangene Reseller Pakete.
|
||||
{t('management.billing.packages.description', 'Übersicht über aktive und vergangene Reseller Pakete.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{packages.length === 0 ? (
|
||||
<EmptyState message="Noch keine Pakete gebucht." />
|
||||
<EmptyState message={t('management.billing.packages.empty', 'Noch keine Pakete gebucht.')} />
|
||||
) : (
|
||||
packages.map((pkg) => (
|
||||
<PackageCard key={pkg.id} pkg={pkg} isActive={pkg.active} />
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
isActive={Boolean(pkg.active)}
|
||||
labels={packageLabels}
|
||||
formatDate={formatDate}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -182,28 +247,33 @@ export default function BillingPage() {
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-sky-500" />
|
||||
Credit Ledger
|
||||
{t('management.billing.ledger.title', 'Credit Ledger')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Alle Zu- und Abbuchungen deines Credits Kontos.
|
||||
{t('management.billing.ledger.description', 'Alle Zu- und Abbuchungen deines Credits-Kontos.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
Export als CSV
|
||||
{t('management.billing.actions.exportCsv', 'Export als CSV')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ledger.entries.length === 0 ? (
|
||||
<EmptyState message="Noch keine Ledger Eintraege vorhanden." />
|
||||
<EmptyState message={t('management.billing.ledger.empty', 'Noch keine Ledger-Einträge vorhanden.')} />
|
||||
) : (
|
||||
<>
|
||||
{ledger.entries.map((entry) => (
|
||||
<LedgerRow key={`${entry.id}-${entry.created_at}`} entry={entry} />
|
||||
<LedgerRow
|
||||
key={`${entry.id}-${entry.created_at}`}
|
||||
entry={entry}
|
||||
resolveReason={resolveReason}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
{ledger.meta && ledger.meta.current_page < ledger.meta.last_page && (
|
||||
<Button variant="outline" className="w-full" onClick={() => void loadMore()} disabled={loadingMore}>
|
||||
{loadingMore ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Mehr laden'}
|
||||
{loadingMore ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.billing.ledger.loadMore', 'Mehr laden')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -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 (
|
||||
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{pkg.package_name}</h3>
|
||||
<p className="text-xs text-slate-600">
|
||||
{formatDate(pkg.purchased_at)} - {formatCurrency(pkg.price)} {pkg.currency ?? 'EUR'}
|
||||
{formatDate(pkg.purchased_at)} · {formatCurrency(pkg.price, pkg.currency ?? 'EUR')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={isActive ? 'bg-amber-500/10 text-amber-700' : 'bg-slate-200 text-slate-700'}>
|
||||
{isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
{isActive ? labels.statusActive : labels.statusInactive}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-3">
|
||||
<span>Genutzte Events: {pkg.used_events}</span>
|
||||
<span>Verfuegbar: {pkg.remaining_events ?? '--'}</span>
|
||||
<span>Ablauf: {formatDate(pkg.expires_at)}</span>
|
||||
<span>
|
||||
{labels.used}: {pkg.used_events}
|
||||
</span>
|
||||
<span>
|
||||
{labels.available}: {pkg.remaining_events ?? '--'}
|
||||
</span>
|
||||
<span>
|
||||
{labels.expires}: {formatDate(pkg.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{mapReason(entry.reason)}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{resolveReason(entry.reason)}</p>
|
||||
{entry.note && <p className="text-xs text-slate-500">{entry.note}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -327,28 +429,3 @@ function BillingSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<DashboardState>({
|
||||
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)}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Neues Event
|
||||
<Plus className="h-4 w-4" /> {t('dashboard.actions.newEvent')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
|
||||
<CalendarDays className="h-4 w-4" /> Alle Events
|
||||
<CalendarDays className="h-4 w-4" /> {t('dashboard.actions.allEvents')}
|
||||
</Button>
|
||||
{events.length === 0 && (
|
||||
<Button
|
||||
@@ -130,22 +138,18 @@ export default function DashboardPage() {
|
||||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" /> Guided Setup
|
||||
<Sparkles className="h-4 w-4" /> {t('dashboard.actions.guidedSetup')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={`Hallo ${user?.name ?? 'Tenant-Admin'}!`}
|
||||
subtitle="Behalte deine Events, Credits und Aufgaben im Blick."
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={actions}>
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -158,24 +162,24 @@ export default function DashboardPage() {
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||||
Starte mit der Welcome Journey
|
||||
{t('dashboard.welcomeCard.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p>Wir begleiten dich durch Pakete, Aufgaben und Galerie-Konfiguration, damit dein Event glänzt.</p>
|
||||
<p>Du kannst jederzeit zur Welcome Journey zurückkehren, auch wenn bereits Events laufen.</p>
|
||||
<p>{t('dashboard.welcomeCard.body1')}</p>
|
||||
<p>{t('dashboard.welcomeCard.body2')}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-full bg-rose-500 text-white shadow-lg shadow-rose-400/40 hover:bg-rose-500/90"
|
||||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
>
|
||||
Jetzt starten
|
||||
{t('dashboard.welcomeCard.cta')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -186,37 +190,37 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||||
Kurzer Ueberblick
|
||||
{t('dashboard.overview.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Wichtigste Kennzahlen deines Tenants auf einen Blick.
|
||||
{t('dashboard.overview.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||||
{activePackage?.package_name ?? 'Kein aktives Package'}
|
||||
{activePackage?.package_name ?? t('dashboard.overview.noPackage')}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="Aktive Events"
|
||||
label={t('dashboard.overview.stats.activeEvents')}
|
||||
value={summary?.active_events ?? publishedEvents.length}
|
||||
hint={`${publishedEvents.length} veroeffentlicht`}
|
||||
hint={t('dashboard.overview.stats.publishedHint', { count: publishedEvents.length })}
|
||||
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Neue Fotos (7 Tage)"
|
||||
label={t('dashboard.overview.stats.newPhotos')}
|
||||
value={summary?.new_photos ?? 0}
|
||||
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Task-Fortschritt"
|
||||
label={t('dashboard.overview.stats.taskProgress')}
|
||||
value={`${Math.round(summary?.task_progress ?? 0)}%`}
|
||||
icon={<Users className="h-5 w-5 text-amber-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Credits"
|
||||
label={t('dashboard.overview.stats.credits')}
|
||||
value={credits}
|
||||
hint={credits <= 1 ? 'Auffuellen empfohlen' : undefined}
|
||||
hint={credits <= 1 ? t('dashboard.overview.stats.lowCredits') : undefined}
|
||||
icon={<CreditCard className="h-5 w-5 text-sky-500" />}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -225,35 +229,35 @@ export default function DashboardPage() {
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">Schnellaktionen</CardTitle>
|
||||
<CardTitle className="text-xl text-slate-900">{t('dashboard.quickActions.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Starte durch mit den wichtigsten Aktionen.
|
||||
{t('dashboard.quickActions.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<QuickAction
|
||||
icon={<Plus className="h-5 w-5" />}
|
||||
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)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Camera className="h-5 w-5" />}
|
||||
label="Fotos moderieren"
|
||||
description="Pruefe neue Uploads."
|
||||
label={t('dashboard.quickActions.moderatePhotos.label')}
|
||||
description={t('dashboard.quickActions.moderatePhotos.description')}
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
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)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<CreditCard className="h-5 w-5" />}
|
||||
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)}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -262,21 +266,21 @@ export default function DashboardPage() {
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">Kommende Events</CardTitle>
|
||||
<CardTitle className="text-xl text-slate-900">{t('dashboard.upcoming.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Die naechsten Termine inklusive Status & Zugriff.
|
||||
{t('dashboard.upcoming.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Einstellungen oeffnen
|
||||
{t('dashboard.upcoming.settings')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
message="Noch keine Termine geplant. Lege dein erstes Event an!"
|
||||
ctaLabel="Event planen"
|
||||
message={t('dashboard.upcoming.empty.message')}
|
||||
ctaLabel={t('dashboard.upcoming.empty.cta')}
|
||||
onCta={() => 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 (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-sky-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={event.status === 'published' ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}>
|
||||
{event.status === 'published' ? 'Live' : 'In Planung'}
|
||||
</Badge>
|
||||
<Button size="sm" variant="outline" onClick={onView} className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40">
|
||||
Oeffnen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">{formattedDate}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={event.status === 'published' ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}>
|
||||
{event.status === 'published' ? labels.live : labels.planning}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onView}
|
||||
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
|
||||
>
|
||||
{labels.open}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<State>({
|
||||
event: null,
|
||||
stats: null,
|
||||
tokens: [],
|
||||
inviteLink: null,
|
||||
error: null,
|
||||
loading: true,
|
||||
busy: false,
|
||||
});
|
||||
const [creatingToken, setCreatingToken] = React.useState(false);
|
||||
const [revokingId, setRevokingId] = React.useState<number | null>(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() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-slate-700">
|
||||
<Button onClick={handleInvite} disabled={busy} className="w-full">
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||
Einladungslink kopieren
|
||||
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
|
||||
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||
Einladungslink erzeugen
|
||||
</Button>
|
||||
{inviteLink && (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
|
||||
{inviteLink}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{tokens.length > 0 ? (
|
||||
tokens.map((token) => (
|
||||
<JoinTokenRow
|
||||
key={token.id}
|
||||
token={token}
|
||||
onCopy={() => handleCopy(token)}
|
||||
onRevoke={() => handleRevoke(token)}
|
||||
revoking={revokingId === token.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
Noch keine Einladungen erstellt. Nutze den Button, um einen neuen QR-Link zu generieren.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
status === 'Aktiv'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: status === 'Abgelaufen'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-slate-200 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-all font-mono text-xs text-slate-600">{token.url}</p>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-slate-500">
|
||||
<span>
|
||||
Nutzung: {token.usage_count}
|
||||
{token.usage_limit ? ` / ${token.usage_limit}` : ''}
|
||||
</span>
|
||||
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
|
||||
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCopy} className="border-amber-200 text-amber-700 hover:bg-amber-100">
|
||||
Kopieren
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRevoke}
|
||||
disabled={revoking || token.revoked_at !== null || !token.is_active}
|
||||
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
|
||||
>
|
||||
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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 = (
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zurueck zur Uebersicht
|
||||
{t('management.members.actions.back', 'Zurück zur Übersicht')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Event Mitglieder"
|
||||
subtitle="Verwalte Moderatoren, Admins und Helfer fuer dieses Event."
|
||||
title={t('management.members.title', 'Event-Mitglieder')}
|
||||
subtitle={t('management.members.subtitle', 'Verwalte Moderatoren, Admins und Helfer für dieses Event.')}
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -150,34 +199,38 @@ export default function EventMembersPage() {
|
||||
<MembersSkeleton />
|
||||
) : !event ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Event nicht gefunden</AlertTitle>
|
||||
<AlertDescription>Bitte kehre zur Eventliste zurueck.</AlertDescription>
|
||||
<AlertTitle>{t('management.members.alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
||||
<AlertDescription>{t('management.members.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{renderName(event.name)}</CardTitle>
|
||||
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Status: {event.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
||||
{t('management.members.eventStatus', {
|
||||
status: event.status === 'published' ? statusLabels.published : statusLabels.draft,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Sparkles className="h-4 w-4 text-pink-500" />
|
||||
Mitglieder
|
||||
{t('management.members.sections.list.title', 'Mitglieder')}
|
||||
</h3>
|
||||
{membersUnavailable ? (
|
||||
<Alert>
|
||||
<AlertTitle>Feature noch nicht aktiviert</AlertTitle>
|
||||
<AlertTitle>{t('management.members.alerts.lockedTitle', 'Feature noch nicht aktiviert')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
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.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : members.length === 0 ? (
|
||||
<EmptyState message="Noch keine Mitglieder eingeladen." />
|
||||
<EmptyState message={t('management.members.sections.list.empty', 'Noch keine Mitglieder eingeladen.')} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
@@ -189,13 +242,23 @@ export default function EventMembersPage() {
|
||||
<p className="text-sm font-semibold text-slate-900">{member.name}</p>
|
||||
<p className="text-xs text-slate-600">{member.email}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span>Status: {member.status ?? 'aktiv'}</span>
|
||||
{member.joined_at && <span>Beigetreten: {formatDate(member.joined_at)}</span>}
|
||||
<span>
|
||||
{t('management.members.labels.status', {
|
||||
status: resolveMemberStatus(member.status),
|
||||
})}
|
||||
</span>
|
||||
{member.joined_at && (
|
||||
<span>
|
||||
{t('management.members.labels.joined', {
|
||||
date: formatDate(member.joined_at),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{mapRole(member.role)}
|
||||
{resolveRole(member.role)}
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={() => void handleRemove(member)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -210,48 +273,48 @@ export default function EventMembersPage() {
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Users className="h-4 w-4 text-emerald-500" />
|
||||
Neues Mitglied einladen
|
||||
{t('management.members.sections.invite.title', 'Neues Mitglied einladen')}
|
||||
</h3>
|
||||
<form className="space-y-3 rounded-2xl border border-emerald-100 bg-white/90 p-4 shadow-sm" onSubmit={handleInvite}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-email">E-Mail</Label>
|
||||
<Label htmlFor="invite-email">{t('management.members.form.emailLabel', 'E-Mail')}</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="person@example.com"
|
||||
placeholder={t('management.members.form.emailPlaceholder', 'person@example.com')}
|
||||
value={invite.email}
|
||||
onChange={(event) => setInvite((prev) => ({ ...prev, email: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-name">Name (optional)</Label>
|
||||
<Label htmlFor="invite-name">{t('management.members.form.nameLabel', 'Name (optional)')}</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="Name"
|
||||
placeholder={t('management.members.form.namePlaceholder', 'Name')}
|
||||
value={invite.name}
|
||||
onChange={(event) => setInvite((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-role">Rolle</Label>
|
||||
<Label htmlFor="invite-role">{t('management.members.form.roleLabel', 'Rolle')}</Label>
|
||||
<Select
|
||||
value={invite.role}
|
||||
onValueChange={(value) => setInvite((prev) => ({ ...prev, role: value }))}
|
||||
>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue placeholder="Rolle waehlen" />
|
||||
<SelectValue placeholder={t('management.members.form.rolePlaceholder', 'Rolle wählen')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tenant_admin">Tenant-Admin</SelectItem>
|
||||
<SelectItem value="member">Mitglied</SelectItem>
|
||||
<SelectItem value="guest">Gast</SelectItem>
|
||||
<SelectItem value="tenant_admin">{roleLabels.tenant_admin}</SelectItem>
|
||||
<SelectItem value="member">{roleLabels.member}</SelectItem>
|
||||
<SelectItem value="guest">{roleLabels.guest}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={inviting}>
|
||||
{inviting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Mail className="h-4 w-4" />}
|
||||
{' '}Einladung senden
|
||||
{' '}{t('management.members.form.submit', 'Einladung senden')}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 = (
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zurueck zur Uebersicht
|
||||
{t('management.tasks.actions.back', 'Zurück zur Übersicht')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Event Tasks"
|
||||
subtitle="Verwalte Aufgaben, die diesem Event zugeordnet sind."
|
||||
title={t('management.tasks.title', 'Event-Tasks')}
|
||||
subtitle={t('management.tasks.subtitle', 'Verwalte Aufgaben, die diesem Event zugeordnet sind.')}
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Hinweis</AlertTitle>
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -115,26 +125,28 @@ export default function EventTasksPage() {
|
||||
<TaskSkeleton />
|
||||
) : !event ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Event nicht gefunden</AlertTitle>
|
||||
<AlertDescription>Bitte kehre zur Eventliste zurueck.</AlertDescription>
|
||||
<AlertTitle>{t('management.tasks.alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
||||
<AlertDescription>{t('management.tasks.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{renderName(event.name)}</CardTitle>
|
||||
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Status: {event.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
||||
{t('management.tasks.eventStatus', {
|
||||
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Sparkles className="h-4 w-4 text-pink-500" />
|
||||
Zugeordnete Tasks
|
||||
{t('management.tasks.sections.assigned.title', 'Zugeordnete Tasks')}
|
||||
</h3>
|
||||
{assignedTasks.length === 0 ? (
|
||||
<EmptyState message="Noch keine Tasks zugewiesen." />
|
||||
<EmptyState message={t('management.tasks.sections.assigned.empty', 'Noch keine Tasks zugewiesen.')} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{assignedTasks.map((task) => (
|
||||
@@ -142,7 +154,7 @@ export default function EventTasksPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{mapPriority(task.priority)}
|
||||
{mapPriority(task.priority, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
|
||||
@@ -155,11 +167,11 @@ export default function EventTasksPage() {
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
||||
Tasks aus Bibliothek hinzufuegen
|
||||
{t('management.tasks.sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
|
||||
</h3>
|
||||
<div className="space-y-2 rounded-2xl border border-emerald-100 bg-white/90 p-3 shadow-sm max-h-72 overflow-y-auto">
|
||||
{availableTasks.length === 0 ? (
|
||||
<EmptyState message="Keine Tasks in der Bibliothek gefunden." />
|
||||
<EmptyState message={t('management.tasks.sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
|
||||
) : (
|
||||
availableTasks.map((task) => (
|
||||
<label key={task.id} className="flex items-start gap-3 rounded-xl border border-transparent p-2 transition hover:border-emerald-200">
|
||||
@@ -180,7 +192,7 @@ export default function EventTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={() => void handleAssign()} disabled={saving || selected.length === 0}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Ausgewaehlte Tasks zuweisen'}
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
|
||||
</Button>
|
||||
</section>
|
||||
</CardContent>
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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<Emotion[]>([]);
|
||||
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) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
|
||||
onClick={() => navigate(`/e/${slug}/tasks`)}
|
||||
onClick={() => {
|
||||
if (!eventKey) return;
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/tasks`);
|
||||
}}
|
||||
>
|
||||
Überspringen und Aufgabe wählen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function GalleryPreview({ slug }: Props) {
|
||||
My Photos
|
||||
</button>
|
||||
</div>
|
||||
<Link to={`/e/${slug}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
|
||||
<Link to={`/e/${encodeURIComponent(slug)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
|
||||
Alle ansehen →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ export default function GalleryPreview({ slug }: Props) {
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{items.map((p: any) => (
|
||||
<Link key={p.id} to={`/e/${slug}/gallery?photoId=${p.id}`} className="block">
|
||||
<Link key={p.id} to={`/e/${encodeURIComponent(slug)}/gallery?photoId=${p.id}`} className="block">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={p.thumbnail_path || p.file_path}
|
||||
@@ -123,4 +123,3 @@ export default function GalleryPreview({ slug }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -2,16 +2,17 @@ import React from 'react';
|
||||
import { usePollStats } from '../polling/usePollStats';
|
||||
|
||||
type EventStatsContextValue = ReturnType<typeof usePollStats> & {
|
||||
eventKey: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const EventStatsContext = React.createContext<EventStatsContextValue | undefined>(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<EventStatsContextValue>(
|
||||
() => ({ 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 <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -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<GuestIdentityContextValue | undefined>(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<GuestIdentityContextValue>(
|
||||
() => ({
|
||||
slug,
|
||||
eventKey,
|
||||
slug: eventKey,
|
||||
name,
|
||||
hydrated,
|
||||
setName: persistName,
|
||||
clearName,
|
||||
reload: loadFromStorage,
|
||||
}),
|
||||
[slug, name, hydrated, persistName, clearName, loadFromStorage]
|
||||
[eventKey, name, hydrated, persistName, clearName, loadFromStorage]
|
||||
);
|
||||
|
||||
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
|
||||
|
||||
@@ -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<EventData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number[]>([]);
|
||||
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) => {
|
||||
|
||||
@@ -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(
|
||||
</ToastProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
@@ -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<AchievementsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -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<GalleryFilter>('latest');
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||
@@ -99,6 +99,10 @@ export default function GalleryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
|
||||
}
|
||||
|
||||
if (eventLoading) {
|
||||
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<EmotionPicker />
|
||||
|
||||
<GalleryPreview slug={slug} />
|
||||
<GalleryPreview slug={token} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanner, setScanner] = useState<Html5Qrcode | null>(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() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
value={eventCode}
|
||||
onChange={(event) => setEventCode(event.target.value)}
|
||||
placeholder="Event-Code eingeben"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
|
||||
disabled={loading || !slug.trim()}
|
||||
disabled={loading || !eventCode.trim()}
|
||||
onClick={() => join()}
|
||||
>
|
||||
{loading ? 'Pruefe...' : 'Event beitreten'}
|
||||
|
||||
@@ -13,12 +13,13 @@ export default function LegalPage() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
const slug = page;
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadLegal() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, {
|
||||
const res = await fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -45,8 +46,10 @@ export default function LegalPage() {
|
||||
return () => controller.abort();
|
||||
}, [page]);
|
||||
|
||||
const fallbackTitle = page ? `Rechtliches: ${page}` : 'Rechtliche Informationen';
|
||||
|
||||
return (
|
||||
<Page title={title || `Rechtliches: ${page}` }>
|
||||
<Page title={title || fallbackTitle}>
|
||||
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -26,11 +26,11 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, slug }: Props) {
|
||||
const params = useParams();
|
||||
const params = useParams<{ token?: string; photoId?: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const photoId = params.photoId;
|
||||
const eventSlug = params.slug || slug;
|
||||
const eventSlug = params.token || slug;
|
||||
|
||||
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -129,9 +129,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
};
|
||||
|
||||
|
||||
// Load task info if photo has task_id and slug is available
|
||||
// Load task info if photo has task_id and event key is available
|
||||
React.useEffect(() => {
|
||||
if (!photo?.task_id || !slug) {
|
||||
if (!photo?.task_id || !eventSlug) {
|
||||
setTask(null);
|
||||
setTaskLoading(false);
|
||||
return;
|
||||
@@ -142,7 +142,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
(async () => {
|
||||
setTaskLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${slug}/tasks`);
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventSlug)}/tasks`);
|
||||
if (res.ok) {
|
||||
const tasks = await res.json();
|
||||
const foundTask = tasks.find((t: any) => t.id === taskId);
|
||||
@@ -173,7 +173,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
setTaskLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [photo?.task_id, slug]);
|
||||
}, [photo?.task_id, eventSlug]);
|
||||
|
||||
async function onLike() {
|
||||
if (liked || !photo) return;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label';
|
||||
import Header from '../components/Header';
|
||||
|
||||
export default function ProfileSetupPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const nav = useNavigate();
|
||||
const { event, loading, error } = useEventData();
|
||||
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
|
||||
@@ -17,11 +17,11 @@ export default function ProfileSetupPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
if (!token) {
|
||||
nav('/');
|
||||
return;
|
||||
}
|
||||
}, [slug, nav]);
|
||||
}, [token, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated) {
|
||||
@@ -34,14 +34,14 @@ export default function ProfileSetupPage() {
|
||||
}
|
||||
|
||||
function submitName() {
|
||||
if (!slug) return;
|
||||
if (!token) return;
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
persistName(trimmedName);
|
||||
nav(`/e/${slug}`);
|
||||
nav(`/e/${token}`);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern des Namens:', e);
|
||||
setSubmitting(false);
|
||||
@@ -67,7 +67,7 @@ export default function ProfileSetupPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
|
||||
<Header slug={slug!} />
|
||||
<Header slug={token!} />
|
||||
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
|
||||
@@ -28,11 +28,12 @@ const TASK_PROGRESS_TARGET = 5;
|
||||
const TIMER_VIBRATION = [0, 60, 120, 60];
|
||||
|
||||
export default function TaskPickerPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { token: slug } = useParams<{ token: string }>();
|
||||
const eventKey = slug ?? '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
|
||||
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(eventKey);
|
||||
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
|
||||
@@ -48,12 +49,12 @@ export default function TaskPickerPage() {
|
||||
const initialEmotionRef = React.useRef(false);
|
||||
|
||||
const fetchTasks = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
if (!eventKey) return;
|
||||
setIsFetching(true);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`);
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
|
||||
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
||||
const payload = await response.json();
|
||||
if (Array.isArray(payload)) {
|
||||
@@ -69,7 +70,7 @@ export default function TaskPickerPage() {
|
||||
setIsFetching(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug]);
|
||||
}, [eventKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchTasks();
|
||||
@@ -206,8 +207,9 @@ export default function TaskPickerPage() {
|
||||
};
|
||||
|
||||
const handleStartUpload = () => {
|
||||
if (!currentTask || !slug) return;
|
||||
navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
if (!currentTask || !eventKey) return;
|
||||
if (!eventKey) return;
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
};
|
||||
|
||||
const handleMarkCompleted = () => {
|
||||
|
||||
@@ -55,7 +55,8 @@ const DEFAULT_PREFS: CameraPreferences = {
|
||||
};
|
||||
|
||||
export default function UploadPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { token: slug } = useParams<{ token: string }>();
|
||||
const eventKey = slug ?? '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { appearance } = useAppearance();
|
||||
@@ -65,8 +66,8 @@ export default function UploadPage() {
|
||||
const taskIdParam = searchParams.get('task');
|
||||
const emotionSlug = searchParams.get('emotion') || '';
|
||||
|
||||
const primerStorageKey = slug ? `guestCameraPrimerDismissed_${slug}` : 'guestCameraPrimerDismissed';
|
||||
const prefsStorageKey = slug ? `guestCameraPrefs_${slug}` : 'guestCameraPrefs';
|
||||
const primerStorageKey = eventKey ? `guestCameraPrimerDismissed_${eventKey}` : 'guestCameraPrimerDismissed';
|
||||
const prefsStorageKey = eventKey ? `guestCameraPrefs_${eventKey}` : 'guestCameraPrefs';
|
||||
|
||||
const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
||||
|
||||
@@ -148,7 +149,7 @@ export default function UploadPage() {
|
||||
setLoadingTask(true);
|
||||
setTaskError(null);
|
||||
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug!)}/tasks`);
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
|
||||
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||
const tasks = await res.json();
|
||||
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
|
||||
@@ -203,15 +204,15 @@ export default function UploadPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [slug, taskId, emotionSlug]);
|
||||
}, [eventKey, taskId, emotionSlug]);
|
||||
|
||||
// Check upload limits
|
||||
useEffect(() => {
|
||||
if (!slug || !task) return;
|
||||
if (!eventKey || !task) return;
|
||||
|
||||
const checkLimits = async () => {
|
||||
try {
|
||||
const pkg = await getEventPackage(slug);
|
||||
const pkg = await getEventPackage(eventKey);
|
||||
setEventPackage(pkg);
|
||||
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
|
||||
setCanUpload(false);
|
||||
@@ -227,7 +228,7 @@ export default function UploadPage() {
|
||||
};
|
||||
|
||||
checkLimits();
|
||||
}, [slug, task]);
|
||||
}, [eventKey, task]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
@@ -444,19 +445,19 @@ export default function UploadPage() {
|
||||
|
||||
const navigateAfterUpload = useCallback(
|
||||
(photoId: number | undefined) => {
|
||||
if (!slug || !task) return;
|
||||
if (!eventKey || !task) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('uploaded', 'true');
|
||||
if (task.id) params.set('task', String(task.id));
|
||||
if (photoId) params.set('photo', String(photoId));
|
||||
if (emotionSlug) params.set('emotion', emotionSlug);
|
||||
navigate(`/e/${encodeURIComponent(slug!)}/gallery?${params.toString()}`);
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?${params.toString()}`);
|
||||
},
|
||||
[emotionSlug, navigate, slug, task]
|
||||
[emotionSlug, navigate, eventKey, task]
|
||||
);
|
||||
|
||||
const handleUsePhoto = useCallback(async () => {
|
||||
if (!slug || !reviewPhoto || !task || !canUpload) return;
|
||||
if (!eventKey || !reviewPhoto || !task || !canUpload) return;
|
||||
setMode('uploading');
|
||||
setUploadProgress(5);
|
||||
setUploadError(null);
|
||||
@@ -470,7 +471,7 @@ export default function UploadPage() {
|
||||
}, 400);
|
||||
|
||||
try {
|
||||
const photoId = await uploadPhoto(slug, reviewPhoto.file, task.id, emotionSlug || undefined);
|
||||
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
|
||||
setUploadProgress(100);
|
||||
setStatusMessage('Upload abgeschlossen.');
|
||||
markCompleted(task.id);
|
||||
@@ -487,7 +488,7 @@ export default function UploadPage() {
|
||||
}
|
||||
setStatusMessage('');
|
||||
}
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task, canUpload]);
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload]);
|
||||
|
||||
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canUpload) return;
|
||||
@@ -532,7 +533,7 @@ export default function UploadPage() {
|
||||
if (!supportsCamera && !task) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<main className="px-4 py-6">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -548,7 +549,7 @@ export default function UploadPage() {
|
||||
if (loadingTask) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Aufgabe und Kamera werden vorbereitet ...</p>
|
||||
@@ -561,7 +562,7 @@ export default function UploadPage() {
|
||||
if (!canUpload) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<main className="px-4 py-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
@@ -629,7 +630,7 @@ export default function UploadPage() {
|
||||
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<main className="relative flex flex-col gap-4 pb-4">
|
||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||
{renderPrimer()}
|
||||
|
||||
@@ -13,6 +13,11 @@ export function usePollGalleryDelta(slug: string) {
|
||||
);
|
||||
|
||||
async function fetchDelta() {
|
||||
if (!slug) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/photos${qs}`, {
|
||||
@@ -85,6 +90,12 @@ export function usePollGalleryDelta(slug: string) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
setPhotos([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
latestAt.current = null;
|
||||
setPhotos([]);
|
||||
|
||||
@@ -12,7 +12,7 @@ type StatsResponse = {
|
||||
latest_photo_at?: string;
|
||||
};
|
||||
|
||||
export function usePollStats(slug: string | null | undefined) {
|
||||
export function usePollStats(eventKey: string | null | undefined) {
|
||||
const [data, setData] = useState<EventStats>({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const timer = useRef<number | null>(null);
|
||||
@@ -20,11 +20,11 @@ export function usePollStats(slug: string | null | undefined) {
|
||||
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
|
||||
);
|
||||
|
||||
const canPoll = Boolean(slug);
|
||||
const canPoll = Boolean(eventKey);
|
||||
|
||||
async function fetchOnce(activeSlug: string) {
|
||||
async function fetchOnce(activeKey: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(activeSlug)}/stats`, {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(activeKey)}/stats`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
if (res.status === 304) return;
|
||||
@@ -52,16 +52,16 @@ export function usePollStats(slug: string | null | undefined) {
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const activeSlug = String(slug);
|
||||
fetchOnce(activeSlug);
|
||||
const activeKey = String(eventKey);
|
||||
fetchOnce(activeKey);
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
if (visible) {
|
||||
timer.current = window.setInterval(() => fetchOnce(activeSlug), 10_000);
|
||||
timer.current = window.setInterval(() => fetchOnce(activeKey), 10_000);
|
||||
}
|
||||
return () => {
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
};
|
||||
}, [slug, visible, canPoll]);
|
||||
}, [eventKey, visible, canPoll]);
|
||||
|
||||
return { ...data, loading };
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ import LegalPage from './pages/LegalPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
function HomeLayout() {
|
||||
const { slug } = useParams();
|
||||
const { token } = useParams();
|
||||
|
||||
if (!slug) {
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header title="Event" />
|
||||
@@ -35,10 +35,10 @@ function HomeLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<GuestIdentityProvider slug={slug}>
|
||||
<EventStatsProvider slug={slug}>
|
||||
<GuestIdentityProvider eventKey={token}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} />
|
||||
<Header slug={token} />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -52,14 +52,14 @@ function HomeLayout() {
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout> },
|
||||
{
|
||||
path: '/setup/:slug',
|
||||
path: '/setup/:token',
|
||||
element: <SetupLayout />,
|
||||
children: [
|
||||
{ index: true, element: <ProfileSetupPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/e/:slug',
|
||||
path: '/e/:token',
|
||||
element: <HomeLayout />,
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
@@ -79,13 +79,13 @@ export const router = createBrowserRouter([
|
||||
]);
|
||||
|
||||
function SetupLayout() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
if (!slug) return null;
|
||||
const { token } = useParams<{ token: string }>();
|
||||
if (!token) return null;
|
||||
return (
|
||||
<GuestIdentityProvider slug={slug}>
|
||||
<EventStatsProvider slug={slug}>
|
||||
<GuestIdentityProvider eventKey={token}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-0">
|
||||
<Header slug={slug} />
|
||||
<Header slug={token} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface EventData {
|
||||
default_locale: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
join_token?: string | null;
|
||||
type?: {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -33,14 +34,14 @@ export interface EventStats {
|
||||
latestPhotoAt: string | null;
|
||||
}
|
||||
|
||||
export async function fetchEvent(slug: string): Promise<EventData> {
|
||||
const res = await fetch(`/api/v1/events/${slug}`);
|
||||
export async function fetchEvent(eventKey: string): Promise<EventData> {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
|
||||
if (!res.ok) throw new Error('Event fetch failed');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchStats(slug: string): Promise<EventStats> {
|
||||
const res = await fetch(`/api/v1/events/${slug}/stats`, {
|
||||
export async function fetchStats(eventKey: string): Promise<EventStats> {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/stats`, {
|
||||
headers: {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
},
|
||||
@@ -48,17 +49,17 @@ export async function fetchStats(slug: string): Promise<EventStats> {
|
||||
if (!res.ok) throw new Error('Stats fetch failed');
|
||||
const json = await res.json();
|
||||
return {
|
||||
onlineGuests: json.onlineGuests ?? 0,
|
||||
tasksSolved: json.tasksSolved ?? 0,
|
||||
latestPhotoAt: json.latestPhotoAt ?? null,
|
||||
onlineGuests: json.online_guests ?? json.onlineGuests ?? 0,
|
||||
tasksSolved: json.tasks_solved ?? json.tasksSolved ?? 0,
|
||||
latestPhotoAt: json.latest_photo_at ?? json.latestPhotoAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEventPackage(slug: string): Promise<EventPackage | null> {
|
||||
const res = await fetch(`/api/v1/events/${slug}/package`);
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/package`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null;
|
||||
throw new Error('Failed to load event package');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function uploadPhoto(slug: string, file: File, taskId?: number, emo
|
||||
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
|
||||
formData.append('device_id', getDeviceId());
|
||||
|
||||
const res = await fetch(`/api/v1/events/${slug}/upload`, {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
@@ -99,4 +99,3 @@ export async function uploadPhoto(slug: string, file: File, taskId?: number, emo
|
||||
const json = await res.json();
|
||||
return json.photo_id ?? json.id ?? json.data?.id ?? 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Appearance = 'light' | 'dark' | 'system';
|
||||
export type Appearance = 'light' | 'dark' | 'system';
|
||||
|
||||
export function useAppearance(): { appearance: Appearance; updateAppearance: (mode: Appearance) => void } {
|
||||
const [appearance, setAppearance] = useState<Appearance>('system');
|
||||
@@ -65,4 +65,4 @@ export function initializeTheme() {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -9,7 +9,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Sun, Moon, Menu, X, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Header: React.FC = () => {
|
||||
@@ -17,10 +19,12 @@ const Header: React.FC = () => {
|
||||
const { t } = useTranslation('auth');
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newAppearance = appearance === 'dark' ? 'light' : 'dark';
|
||||
updateAppearance(newAppearance);
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleLanguageChange = useCallback(async (value: string) => {
|
||||
@@ -41,70 +45,104 @@ const Header: React.FC = () => {
|
||||
i18n.changeLanguage(value);
|
||||
// Reload only the locale prop to update the page props
|
||||
router.reload({ only: ['locale'] });
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to change locale:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setMobileMenuOpen]);
|
||||
|
||||
const handleLogout = () => {
|
||||
router.post('/logout');
|
||||
router.post('/logout', {}, {
|
||||
onFinish: () => setMobileMenuOpen(false),
|
||||
});
|
||||
};
|
||||
|
||||
const navItems = useMemo(() => ([
|
||||
{
|
||||
key: 'home',
|
||||
label: t('header.home', 'Home'),
|
||||
href: localizedPath('/'),
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
label: t('header.packages', 'Pakete'),
|
||||
href: localizedPath('/packages'),
|
||||
},
|
||||
{
|
||||
key: 'blog',
|
||||
label: t('header.blog', 'Blog'),
|
||||
href: localizedPath('/blog'),
|
||||
},
|
||||
{
|
||||
key: 'occasions',
|
||||
label: t('header.occasions.label', 'Anlässe'),
|
||||
children: [
|
||||
{
|
||||
key: 'wedding',
|
||||
label: t('header.occasions.wedding', 'Hochzeit'),
|
||||
href: localizedPath('/anlaesse/hochzeit'),
|
||||
},
|
||||
{
|
||||
key: 'birthday',
|
||||
label: t('header.occasions.birthday', 'Geburtstag'),
|
||||
href: localizedPath('/anlaesse/geburtstag'),
|
||||
},
|
||||
{
|
||||
key: 'corporate',
|
||||
label: t('header.occasions.corporate', 'Firmenevent'),
|
||||
href: localizedPath('/anlaesse/firmenevent'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
label: t('header.contact', 'Kontakt'),
|
||||
href: localizedPath('/kontakt'),
|
||||
},
|
||||
]), [localizedPath, t]);
|
||||
|
||||
const handleNavSelect = useCallback(() => setMobileMenuOpen(false), []);
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 z-50 w-full bg-white dark:bg-gray-900 shadow-lg border-b-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href={localizedPath('/')} className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
Die Fotospiel.App
|
||||
</Link>
|
||||
<nav className="flex space-x-8">
|
||||
<Button asChild variant="ghost" className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200">
|
||||
<Link href={localizedPath('/')}>
|
||||
{t('header.home', 'Home')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200">
|
||||
<Link href={localizedPath('/packages')}>
|
||||
{t('header.packages', 'Pakete')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200">
|
||||
<Link href={localizedPath('/blog')}>
|
||||
{t('header.blog', 'Blog')}
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200">
|
||||
Anlässe
|
||||
<nav className="hidden lg:flex items-center space-x-8">
|
||||
{navItems.map((item) => (
|
||||
item.children ? (
|
||||
<DropdownMenu key={item.key}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200">
|
||||
{item.label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{item.children.map((child) => (
|
||||
<DropdownMenuItem asChild key={child.key}>
|
||||
<Link href={child.href} className="w-full flex items-center">
|
||||
{child.label}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
key={item.key}
|
||||
variant="ghost"
|
||||
className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200"
|
||||
>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={localizedPath('/anlaesse/hochzeit')} className="w-full flex items-center">
|
||||
{t('header.occasions.wedding', 'Hochzeit')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={localizedPath('/anlaesse/geburtstag')} className="w-full flex items-center">
|
||||
{t('header.occasions.birthday', 'Geburtstag')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={localizedPath('/anlaesse/firmenevent')} className="w-full flex items-center">
|
||||
{t('header.occasions.corporate', 'Firmenevent')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button asChild variant="ghost" className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200">
|
||||
<Link href={localizedPath('/kontakt')}>
|
||||
{t('header.contact', 'Kontakt')}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -175,10 +213,138 @@ const Header: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center lg:hidden">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={mobileMenuOpen ? 'Navigation schließen' : 'Navigation öffnen'}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="flex h-full flex-col gap-6 overflow-y-auto bg-white dark:bg-gray-950">
|
||||
<SheetHeader className="text-left">
|
||||
<SheetTitle className="text-xl font-semibold">Menü</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-4">
|
||||
{navItems.map((item) => (
|
||||
item.children ? (
|
||||
<div key={item.key} className="space-y-2">
|
||||
<p className="text-sm font-semibold uppercase text-muted-foreground">{item.label}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{item.children.map((child) => (
|
||||
<SheetClose asChild key={child.key}>
|
||||
<Link
|
||||
href={child.href}
|
||||
className="flex items-center justify-between rounded-md border border-transparent bg-gray-50 px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:bg-gray-900/40 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={handleNavSelect}
|
||||
>
|
||||
<span>{child.label}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SheetClose asChild key={item.key}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
onClick={handleNavSelect}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
)
|
||||
))}
|
||||
</nav>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Darstellung</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Sun className={cn("h-4 w-4", appearance === "dark" && "hidden")} />
|
||||
<Moon className={cn("h-4 w-4", appearance !== "dark" && "hidden")} />
|
||||
<span className="sr-only">Theme Toggle</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Sprache</span>
|
||||
<Select value={i18n.language || 'de'} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="h-10 w-full">
|
||||
<SelectValue placeholder={t('common.ui.language_select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="de">Deutsch</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{auth.user ? (
|
||||
<>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href={localizedPath('/profile')}
|
||||
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
onClick={handleNavSelect}
|
||||
>
|
||||
Profil
|
||||
</Link>
|
||||
</SheetClose>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href={localizedPath('/profile/orders')}
|
||||
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
onClick={handleNavSelect}
|
||||
>
|
||||
Bestellungen
|
||||
</Link>
|
||||
</SheetClose>
|
||||
<Button variant="destructive" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href={localizedPath('/login')}
|
||||
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
onClick={handleNavSelect}
|
||||
>
|
||||
{t('header.login')}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href={localizedPath('/register')}
|
||||
className="rounded-md bg-pink-500 px-3 py-2 text-base font-semibold text-white transition hover:bg-pink-600"
|
||||
onClick={handleNavSelect}
|
||||
>
|
||||
{t('header.register')}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
|
||||
@@ -59,7 +59,7 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
||||
/>
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<link rel="alternate" hreflang="x-default" href="https://fotospiel.app/" />
|
||||
<link rel="alternate" hrefLang="x-default" href="https://fotospiel.app/" />
|
||||
</Head>
|
||||
<div className="min-h-screen bg-white">
|
||||
<header className="bg-white shadow-sm">
|
||||
@@ -77,4 +77,4 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketingLayout;
|
||||
export default MarketingLayout;
|
||||
|
||||
@@ -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 (
|
||||
<div className="container mx-auto py-8">
|
||||
@@ -58,4 +59,4 @@ const ProfileOrders = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileOrders;
|
||||
export default ProfileOrders;
|
||||
|
||||
@@ -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<string, string>; 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<RegisterFormFields>({
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 }
|
||||
<div className="space-y-6">
|
||||
{currentStep === "package" && <PackageStep />}
|
||||
{currentStep === "auth" && <AuthStep privacyHtml={privacyHtml} />}
|
||||
{currentStep === "payment" && <PaymentStep stripePublishableKey={stripePublishableKey} />}
|
||||
{currentStep === "payment" && (
|
||||
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} />
|
||||
)}
|
||||
{currentStep === "confirmation" && <ConfirmationStep />}
|
||||
</div>
|
||||
|
||||
@@ -102,6 +105,7 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
|
||||
initialPackage,
|
||||
packageOptions,
|
||||
stripePublishableKey,
|
||||
paypalClientId,
|
||||
privacyHtml,
|
||||
initialAuthUser,
|
||||
initialStep,
|
||||
@@ -115,7 +119,7 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
|
||||
initialAuthUser={initialAuthUser ?? undefined}
|
||||
initialIsAuthenticated={Boolean(initialAuthUser)}
|
||||
>
|
||||
<WizardBody stripePublishableKey={stripePublishableKey} privacyHtml={privacyHtml} />
|
||||
<WizardBody stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} privacyHtml={privacyHtml} />
|
||||
</CheckoutWizardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
<HeadingSmall title={t('auth.settings.password.section_title', 'Update password')} description={t('auth.settings.password.description', 'Ensure your account is using a long, random password to stay secure')} />
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
{...PasswordController.update.form()}
|
||||
options={{
|
||||
preserveScroll: true,
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { store } from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import { send } from '@/routes/verification';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Transition } from '@headlessui/react';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Controllers\Api\EventPublicController;
|
||||
use App\Http\Controllers\Api\Tenant\EventController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
||||
use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||
@@ -48,11 +49,19 @@ Route::prefix('v1')->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');
|
||||
|
||||
Reference in New Issue
Block a user