added missing translations
This commit is contained in:
65
app/Filament/Resources/TenantFeedbackResource.php
Normal file
65
app/Filament/Resources/TenantFeedbackResource.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TenantFeedbackResource\Pages\ListTenantFeedback;
|
||||
use App\Filament\Resources\TenantFeedbackResource\Pages\ViewTenantFeedback;
|
||||
use App\Filament\Resources\TenantFeedbackResource\Schemas\TenantFeedbackForm;
|
||||
use App\Filament\Resources\TenantFeedbackResource\Schemas\TenantFeedbackInfolist;
|
||||
use App\Filament\Resources\TenantFeedbackResource\Tables\TenantFeedbackTable;
|
||||
use App\Models\TenantFeedback;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantFeedbackResource extends Resource
|
||||
{
|
||||
protected static ?string $model = TenantFeedback::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleBottomCenterText;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 120;
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return TenantFeedbackForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return TenantFeedbackInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return TenantFeedbackTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('Feedback & Support');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListTenantFeedback::route('/'),
|
||||
'view' => ViewTenantFeedback::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantFeedbackResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantFeedbackResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantFeedback extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantFeedbackResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantFeedbackResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantFeedbackResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewTenantFeedback extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TenantFeedbackResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantFeedbackResource\Schemas;
|
||||
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class TenantFeedbackForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
//
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantFeedbackResource\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantFeedbackInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('Überblick'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('tenant.name')->label(__('Tenant'))->placeholder('—'),
|
||||
TextEntry::make('event.name')->label(__('Event'))->placeholder('—'),
|
||||
TextEntry::make('category')
|
||||
->label(__('Kategorie'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : '—'),
|
||||
TextEntry::make('sentiment')
|
||||
->label(__('Stimmung'))
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'positive' => 'success',
|
||||
'neutral' => 'warning',
|
||||
'negative' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—'),
|
||||
TextEntry::make('rating')
|
||||
->label(__('Rating'))
|
||||
->formatStateUsing(fn (?int $state) => $state ? sprintf('%d/5', $state) : '—'),
|
||||
TextEntry::make('created_at')
|
||||
->label(__('Eingegangen'))
|
||||
->since(),
|
||||
]),
|
||||
Section::make(__('Inhalt'))
|
||||
->columns(1)
|
||||
->schema([
|
||||
TextEntry::make('title')
|
||||
->label(__('Betreff'))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('message')
|
||||
->label(__('Nachricht'))
|
||||
->markdown()
|
||||
->placeholder('—'),
|
||||
]),
|
||||
Section::make(__('Metadaten'))
|
||||
->schema([
|
||||
KeyValueEntry::make('metadata')
|
||||
->label(__('Metadata'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantFeedbackResource\Tables;
|
||||
|
||||
use App\Models\TenantFeedback;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantFeedbackTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label(__('Eingegangen'))
|
||||
->since()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label(__('Tenant'))
|
||||
->searchable()
|
||||
->limit(30),
|
||||
Tables\Columns\TextColumn::make('event.name')
|
||||
->label(__('Event'))
|
||||
->limit(30)
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->label(__('Kategorie'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => Str::headline($state))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('sentiment')
|
||||
->label(__('Stimmung'))
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'positive' => 'success',
|
||||
'neutral' => 'warning',
|
||||
'negative' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('rating')
|
||||
->label(__('Rating'))
|
||||
->formatStateUsing(fn (?int $state) => $state ? sprintf('%d/5', $state) : '—')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('message')
|
||||
->label(__('Nachricht'))
|
||||
->limit(60)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('sentiment')
|
||||
->label(__('Stimmung'))
|
||||
->options([
|
||||
'positive' => __('Positiv'),
|
||||
'neutral' => __('Neutral'),
|
||||
'negative' => __('Negativ'),
|
||||
]),
|
||||
SelectFilter::make('category')
|
||||
->label(__('Kategorie'))
|
||||
->options(fn () => TenantFeedback::query()
|
||||
->whereNotNull('category')
|
||||
->orderBy('category')
|
||||
->pluck('category', 'category')
|
||||
->toArray()),
|
||||
])
|
||||
->recordActions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@@ -1230,6 +1230,7 @@ class EventPublicController extends BaseController
|
||||
|
||||
$branding = $this->buildGalleryBranding($event);
|
||||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||||
$settings = is_array($event->settings) ? $event->settings : [];
|
||||
|
||||
return response()->json([
|
||||
'event' => [
|
||||
@@ -1238,6 +1239,8 @@ class EventPublicController extends BaseController
|
||||
'slug' => $event->slug,
|
||||
'description' => $this->translateLocalized($event->description, $locale, ''),
|
||||
'gallery_expires_at' => $expiresAt?->toIso8601String(),
|
||||
'guest_downloads_enabled' => (bool) ($settings['guest_downloads_enabled'] ?? true),
|
||||
'guest_sharing_enabled' => (bool) ($settings['guest_sharing_enabled'] ?? true),
|
||||
],
|
||||
'branding' => $branding,
|
||||
]);
|
||||
|
||||
@@ -5,9 +5,12 @@ namespace App\Http\Controllers\Api\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\TenantFeedback;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TenantFeedbackSubmitted;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class TenantFeedbackController extends Controller
|
||||
{
|
||||
@@ -52,6 +55,15 @@ class TenantFeedbackController extends Controller
|
||||
'metadata' => $validated['metadata'] ?? null,
|
||||
]);
|
||||
|
||||
$recipients = User::query()
|
||||
->where('role', 'super_admin')
|
||||
->whereNotNull('email')
|
||||
->get();
|
||||
|
||||
if ($recipients->isNotEmpty()) {
|
||||
Notification::send($recipients, new TenantFeedbackSubmitted($feedback));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Feedback gespeichert',
|
||||
'data' => [
|
||||
|
||||
@@ -8,6 +8,8 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
use function app;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
@@ -54,6 +56,9 @@ class EventResource extends JsonResource
|
||||
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
|
||||
'settings' => $settings,
|
||||
'event_type_id' => $this->event_type_id,
|
||||
'event_type' => $this->whenLoaded('eventType', function () {
|
||||
return new EventTypeResource($this->eventType);
|
||||
}),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
'photo_count' => (int) ($this->photos_count ?? 0),
|
||||
|
||||
84
app/Notifications/TenantFeedbackSubmitted.php
Normal file
84
app/Notifications/TenantFeedbackSubmitted.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Filament\Resources\TenantFeedbackResource;
|
||||
use App\Models\TenantFeedback;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantFeedbackSubmitted extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(protected TenantFeedback $feedback)
|
||||
{
|
||||
$this->feedback->loadMissing('tenant', 'event');
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$tenantName = $this->resolveName($this->feedback->tenant?->name) ?: __('emails.tenant_feedback.unknown_tenant');
|
||||
$eventName = $this->resolveName($this->feedback->event?->name) ?: ($this->feedback->metadata['event_name'] ?? null);
|
||||
$sentiment = $this->feedback->sentiment ? Str::headline($this->feedback->sentiment) : __('emails.tenant_feedback.unknown');
|
||||
$rating = $this->feedback->rating ? sprintf('%d/5', $this->feedback->rating) : null;
|
||||
|
||||
$subject = __('emails.tenant_feedback.subject', [
|
||||
'tenant' => $tenantName,
|
||||
'sentiment' => $sentiment,
|
||||
]);
|
||||
|
||||
$mail = (new MailMessage())
|
||||
->subject($subject)
|
||||
->line(__('emails.tenant_feedback.tenant', ['tenant' => $tenantName]))
|
||||
->line(__('emails.tenant_feedback.category', ['category' => $this->feedback->category ? Str::headline($this->feedback->category) : '—']))
|
||||
->line(__('emails.tenant_feedback.sentiment', ['sentiment' => $sentiment]));
|
||||
|
||||
if ($eventName) {
|
||||
$mail->line(__('emails.tenant_feedback.event', ['event' => $eventName]));
|
||||
}
|
||||
|
||||
if ($rating) {
|
||||
$mail->line(__('emails.tenant_feedback.rating', ['rating' => $rating]));
|
||||
}
|
||||
|
||||
if ($this->feedback->title) {
|
||||
$mail->line(__('emails.tenant_feedback.title', ['subject' => $this->feedback->title]));
|
||||
}
|
||||
|
||||
if ($this->feedback->message) {
|
||||
$mail->line(__('emails.tenant_feedback.message'))->line($this->feedback->message);
|
||||
}
|
||||
|
||||
$url = TenantFeedbackResource::getUrl('view', ['record' => $this->feedback], panel: 'superadmin');
|
||||
|
||||
if ($url) {
|
||||
$mail->action(__('emails.tenant_feedback.open'), $url);
|
||||
}
|
||||
|
||||
$mail->line(__('emails.tenant_feedback.received_at', ['date' => $this->feedback->created_at?->toDayDateTimeString()]));
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
protected function resolveName(mixed $name): ?string
|
||||
{
|
||||
if (is_string($name) && $name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
if (is_array($name)) {
|
||||
return $name['de'] ?? $name['en'] ?? reset($name) ?: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -294,12 +294,8 @@ function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: strin
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-2xl border border-slate-200/70 bg-white px-3 py-2 text-left text-sm font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-white/10 dark:text-slate-200"
|
||||
>
|
||||
<span>
|
||||
{activeTab?.label ?? t('navigation.tabs.active', { defaultValue: 'Bereich wählen' })}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-rose-500">
|
||||
{t('navigation.tabs.open', { defaultValue: 'Tabs' })}
|
||||
</span>
|
||||
<span>{activeTab?.label ?? t('navigation.tabs.active')}</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-rose-500">{t('navigation.tabs.open')}</span>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
@@ -308,10 +304,10 @@ function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: strin
|
||||
>
|
||||
<SheetHeader className="px-4 pt-0 text-left">
|
||||
<SheetTitle className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('navigation.tabs.title', { defaultValue: 'Bereich auswählen' })}
|
||||
{t('navigation.tabs.title')}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('navigation.tabs.subtitle', { defaultValue: 'Wechsle schnell zwischen Event-Bereichen.' })}
|
||||
{t('navigation.tabs.subtitle')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 grid gap-2 px-4">
|
||||
|
||||
@@ -34,7 +34,7 @@ interface TenantNotification {
|
||||
|
||||
export function NotificationCenter() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { t } = useTranslation('management');
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [notifications, setNotifications] = React.useState<TenantNotification[]>([]);
|
||||
@@ -104,7 +104,7 @@ export function NotificationCenter() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative rounded-full border border-transparent text-slate-600 hover:text-rose-600 dark:text-slate-200"
|
||||
aria-label={t('notifications.trigger', { defaultValue: 'Benachrichtigungen' })}
|
||||
aria-label={t('notifications.trigger')}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 ? (
|
||||
@@ -116,9 +116,9 @@ export function NotificationCenter() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 space-y-1 p-0">
|
||||
<DropdownMenuLabel className="flex items-center justify-between py-2">
|
||||
<span>{t('notifications.title', { defaultValue: 'Notifications' })}</span>
|
||||
<span>{t('notifications.title')}</span>
|
||||
{!loading && unreadCount === 0 ? (
|
||||
<Badge variant="outline">{t('notifications.empty', { defaultValue: 'Aktuell ruhig' })}</Badge>
|
||||
<Badge variant="outline">{t('notifications.empty')}</Badge>
|
||||
) : null}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -131,7 +131,7 @@ export function NotificationCenter() {
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto p-1">
|
||||
{visibleNotifications.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-slate-500">
|
||||
{t('notifications.empty.message', { defaultValue: 'Alles erledigt – wir melden uns bei Neuigkeiten.' })}
|
||||
{t('notifications.empty.message')}
|
||||
</p>
|
||||
) : (
|
||||
visibleNotifications.map((item) => (
|
||||
@@ -163,7 +163,7 @@ export function NotificationCenter() {
|
||||
className="h-7 rounded-full px-3 text-xs text-slate-500 hover:text-rose-600"
|
||||
onClick={() => handleDismiss(item.id)}
|
||||
>
|
||||
{t('notifications.action.dismiss', { defaultValue: 'Ausblenden' })}
|
||||
{t('notifications.action.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +183,7 @@ export function NotificationCenter() {
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('notifications.action.refresh', { defaultValue: 'Neue Hinweise laden' })}
|
||||
{t('notifications.action.refresh')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -208,13 +208,11 @@ function buildNotifications({
|
||||
if (events.length === 0) {
|
||||
items.push({
|
||||
id: 'no-events',
|
||||
title: t('notifications.noEvents.title', { defaultValue: 'Legen wir los' }),
|
||||
description: t('notifications.noEvents.description', {
|
||||
defaultValue: 'Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.',
|
||||
}),
|
||||
title: t('notifications.noEvents.title'),
|
||||
description: t('notifications.noEvents.description'),
|
||||
tone: 'warning',
|
||||
action: {
|
||||
label: t('notifications.noEvents.cta', { defaultValue: 'Event erstellen' }),
|
||||
label: t('notifications.noEvents.cta'),
|
||||
onSelect: () => navigate(ADMIN_EVENT_CREATE_PATH),
|
||||
},
|
||||
});
|
||||
@@ -226,14 +224,12 @@ function buildNotifications({
|
||||
if (event.status !== 'published') {
|
||||
items.push({
|
||||
id: `draft-${event.id}`,
|
||||
title: t('notifications.draftEvent.title', { defaultValue: 'Event noch als Entwurf' }),
|
||||
description: t('notifications.draftEvent.description', {
|
||||
defaultValue: 'Veröffentliche das Event, um Einladungen und Galerie freizugeben.',
|
||||
}),
|
||||
title: t('notifications.draftEvent.title'),
|
||||
description: t('notifications.draftEvent.description'),
|
||||
tone: 'info',
|
||||
action: event.slug
|
||||
? {
|
||||
label: t('notifications.draftEvent.cta', { defaultValue: 'Event öffnen' }),
|
||||
label: t('notifications.draftEvent.cta'),
|
||||
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
|
||||
}
|
||||
: undefined,
|
||||
@@ -244,37 +240,33 @@ function buildNotifications({
|
||||
if (eventDate && eventDate > now) {
|
||||
const days = Math.round((eventDate - now) / (1000 * 60 * 60 * 24));
|
||||
if (days <= 7) {
|
||||
items.push({
|
||||
id: `upcoming-${event.id}`,
|
||||
title: t('notifications.upcomingEvent.title', { defaultValue: 'Event startet bald' }),
|
||||
description: t('notifications.upcomingEvent.description', {
|
||||
defaultValue: days === 0
|
||||
? 'Heute findet ein Event statt – checke Uploads und Tasks.'
|
||||
: `Noch ${days} Tage – bereite Einladungen und Aufgaben vor.`,
|
||||
}),
|
||||
tone: 'info',
|
||||
action: event.slug
|
||||
? {
|
||||
label: t('notifications.upcomingEvent.cta', { defaultValue: 'Zum Event' }),
|
||||
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
id: `upcoming-${event.id}`,
|
||||
title: t('notifications.upcomingEvent.title'),
|
||||
description: days === 0
|
||||
? t('notifications.upcomingEvent.description_today')
|
||||
: t('notifications.upcomingEvent.description_days', { count: days }),
|
||||
tone: 'info',
|
||||
action: event.slug
|
||||
? {
|
||||
label: t('notifications.upcomingEvent.cta'),
|
||||
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pendingUploads = Number(event.pending_photo_count ?? 0);
|
||||
if (pendingUploads > 0) {
|
||||
items.push({
|
||||
id: `pending-uploads-${event.id}`,
|
||||
title: t('notifications.pendingUploads.title', { defaultValue: 'Uploads warten auf Freigabe' }),
|
||||
description: t('notifications.pendingUploads.description', {
|
||||
defaultValue: `${pendingUploads} neue Uploads benötigen Moderation.`,
|
||||
}),
|
||||
title: t('notifications.pendingUploads.title'),
|
||||
description: t('notifications.pendingUploads.description', { count: pendingUploads }),
|
||||
tone: 'warning',
|
||||
action: event.slug
|
||||
? {
|
||||
label: t('notifications.pendingUploads.cta', { defaultValue: 'Uploads öffnen' }),
|
||||
label: t('notifications.pendingUploads.cta'),
|
||||
onSelect: () => navigate(`${ADMIN_EVENT_VIEW_PATH(event.slug!)}#photos`),
|
||||
}
|
||||
: undefined,
|
||||
@@ -285,18 +277,16 @@ function buildNotifications({
|
||||
if ((summary?.new_photos ?? 0) > 0) {
|
||||
items.push({
|
||||
id: 'summary-new-photos',
|
||||
title: t('notifications.newPhotos.title', { defaultValue: 'Neue Fotos eingetroffen' }),
|
||||
description: t('notifications.newPhotos.description', {
|
||||
defaultValue: `${summary?.new_photos ?? 0} Uploads warten auf dich.`,
|
||||
}),
|
||||
title: t('notifications.newPhotos.title'),
|
||||
description: t('notifications.newPhotos.description', { count: summary?.new_photos ?? 0 }),
|
||||
tone: 'success',
|
||||
action: primary?.slug
|
||||
? {
|
||||
label: t('notifications.newPhotos.cta', { defaultValue: 'Galerie öffnen' }),
|
||||
label: t('notifications.newPhotos.cta'),
|
||||
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(primary.slug!)),
|
||||
}
|
||||
: {
|
||||
label: t('notifications.newPhotos.ctaFallback', { defaultValue: 'Events ansehen' }),
|
||||
label: t('notifications.newPhotos.ctaFallback'),
|
||||
onSelect: () => navigate(ADMIN_EVENTS_PATH),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,5 +34,10 @@
|
||||
"return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.",
|
||||
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
|
||||
"appearance_label": "Darstellung"
|
||||
},
|
||||
"redirecting": "Weiterleitung zum Login …",
|
||||
"processing": {
|
||||
"title": "Anmeldung wird verarbeitet …",
|
||||
"copy": "Einen Moment bitte, wir bereiten dein Dashboard vor."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,9 +278,18 @@
|
||||
"untitled": "Unbenanntes Event"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Event-Tasks",
|
||||
"subtitle": "Verwalte Aufgaben, die diesem Event zugeordnet sind.",
|
||||
"eventMenu": {
|
||||
"summary": "Übersicht",
|
||||
"photos": "Uploads",
|
||||
"tasks": "Aufgaben",
|
||||
"invites": "Einladungen",
|
||||
"branding": "Branding",
|
||||
"photobooth": "Photobooth",
|
||||
"recap": "Nachbereitung"
|
||||
},
|
||||
"eventTasks": {
|
||||
"title": "Aufgaben & Missionen",
|
||||
"subtitle": "Stelle Mission Cards und Aufgaben für dieses Event zusammen.",
|
||||
"actions": {
|
||||
"back": "Zurück zur Übersicht",
|
||||
"assign": "Ausgewählte Tasks zuweisen"
|
||||
@@ -290,7 +299,8 @@
|
||||
"load": "Event-Tasks konnten nicht geladen werden.",
|
||||
"assign": "Tasks konnten nicht zugewiesen werden.",
|
||||
"photoOnlyEnable": "Foto-Modus konnte nicht aktiviert werden.",
|
||||
"photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden."
|
||||
"photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden.",
|
||||
"collections": "Kollektionen konnten nicht geladen werden."
|
||||
},
|
||||
"emotions": {
|
||||
"error": "Emotionen konnten nicht geladen werden."
|
||||
@@ -299,10 +309,28 @@
|
||||
"notFoundTitle": "Event nicht gefunden",
|
||||
"notFoundDescription": "Bitte kehre zur Eventliste zurück."
|
||||
},
|
||||
"tabs": {
|
||||
"tasks": "Aufgaben",
|
||||
"packs": "Mission Packs"
|
||||
},
|
||||
"eventStatus": "Status: {{status}}",
|
||||
"summary": {
|
||||
"assigned": "Zugeordnete Tasks",
|
||||
"library": "Bibliothek",
|
||||
"mode": "Aktiver Modus",
|
||||
"tasksMode": "Mission Cards",
|
||||
"photoOnly": "Nur Fotos"
|
||||
},
|
||||
"library": {
|
||||
"hintTitle": "Weitere Vorlagen in der Aufgaben-Bibliothek",
|
||||
"hintCopy": "Lege Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.",
|
||||
"open": "Aufgaben-Bibliothek öffnen"
|
||||
},
|
||||
"sections": {
|
||||
"assigned": {
|
||||
"title": "Zugeordnete Tasks",
|
||||
"search": "Aufgaben suchen...",
|
||||
"noResults": "Keine Aufgaben zum Suchbegriff.",
|
||||
"empty": "Noch keine Tasks zugewiesen."
|
||||
},
|
||||
"library": {
|
||||
@@ -316,15 +344,32 @@
|
||||
"high": "Hoch",
|
||||
"urgent": "Dringend"
|
||||
},
|
||||
"modes": {
|
||||
"title": "Aufgaben & Foto-Modus",
|
||||
"photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.",
|
||||
"tasksHint": "Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.",
|
||||
"modes": {
|
||||
"title": "Aufgaben & Foto-Modus",
|
||||
"photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.",
|
||||
"tasksHint": "Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.",
|
||||
"photoOnly": "Foto-Modus",
|
||||
"tasks": "Aufgaben aktiv",
|
||||
"switchLabel": "Foto-Modus aktivieren",
|
||||
"switchLabel": "Aufgaben aktivieren/deaktivieren",
|
||||
"updating": "Einstellung wird gespeichert ..."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Mission Packs",
|
||||
"subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.",
|
||||
"viewAll": "Alle Kollektionen ansehen",
|
||||
"errorTitle": "Kollektionen nicht verfügbar",
|
||||
"empty": "Keine empfohlenen Kollektionen gefunden.",
|
||||
"tasksCount": "{{count}} Aufgaben",
|
||||
"genericType": "Allgemein",
|
||||
"global": "Global",
|
||||
"custom": "Custom",
|
||||
"recommended": "Empfohlen",
|
||||
"optional": "Optional",
|
||||
"importCta": "Mission Pack importieren",
|
||||
"imported": "Kollektion erfolgreich importiert",
|
||||
"importFailed": "Mission Pack konnte nicht importiert werden",
|
||||
"error": "Kollektionen konnten nicht geladen werden."
|
||||
},
|
||||
"toolkit": {
|
||||
"titleFallback": "Event-Day Toolkit",
|
||||
"subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.",
|
||||
@@ -772,6 +817,11 @@
|
||||
"workspace": {
|
||||
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.",
|
||||
"toolkitSubtitle": "Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.",
|
||||
"hero": {
|
||||
"badge": "Event",
|
||||
"description": "Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.",
|
||||
"liveBadge": "Live?"
|
||||
},
|
||||
"sections": {
|
||||
"statusTitle": "Eventstatus & Sichtbarkeit",
|
||||
"statusSubtitle": "Aktiviere dein Event für Gäste oder verstecke es vorübergehend."
|
||||
@@ -780,6 +830,7 @@
|
||||
"status": "Status",
|
||||
"active": "Aktiv für Gäste",
|
||||
"date": "Eventdatum",
|
||||
"noDate": "Kein Datum",
|
||||
"eventType": "Event-Typ",
|
||||
"insights": "Letzte Aktivität",
|
||||
"uploadsTotal": "{{count}} Uploads gesamt",
|
||||
@@ -832,6 +883,7 @@
|
||||
"activeInvites": "Aktive Einladungen"
|
||||
},
|
||||
"invites": {
|
||||
"badge": "Einladungen",
|
||||
"title": "QR-Einladungen",
|
||||
"subtitle": "Behält aktive Einladungen und Layouts im Blick.",
|
||||
"activeCount": "{{count}} aktiv",
|
||||
@@ -840,11 +892,69 @@
|
||||
"manage": "Layouts & Einladungen verwalten"
|
||||
},
|
||||
"tasks": {
|
||||
"badge": "Aufgaben",
|
||||
"title": "Aktive Aufgaben",
|
||||
"subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.",
|
||||
"summary": "{{completed}} von {{total}} erledigt",
|
||||
"empty": "Noch keine Aufgaben zugewiesen.",
|
||||
"manage": "Aufgabenbereich öffnen"
|
||||
"manage": "Aufgabenbereich öffnen",
|
||||
"status": {
|
||||
"completed": "Erledigt",
|
||||
"open": "Offen"
|
||||
}
|
||||
},
|
||||
"recap": {
|
||||
"badge": "Nachbereitung",
|
||||
"subtitle": "Abschluss, Export und Galerie-Laufzeit verwalten.",
|
||||
"galleryTitle": "Galerie-Status",
|
||||
"galleryCounts": "{{photos}} Fotos, {{pending}} offen, {{likes}} Likes",
|
||||
"open": "Offen",
|
||||
"closed": "Geschlossen",
|
||||
"openGallery": "Galerie öffnen",
|
||||
"closeGallery": "Galerie schließen",
|
||||
"moderate": "Uploads ansehen",
|
||||
"shareGuests": "Gäste-Galerie teilen",
|
||||
"shareLink": "Gäste-Link",
|
||||
"noPublicUrl": "Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.",
|
||||
"copyLink": "Link kopieren",
|
||||
"copySuccess": "Link kopiert",
|
||||
"copyError": "Link konnte nicht geteilt werden.",
|
||||
"qrTitle": "QR-Code teilen",
|
||||
"qrDownload": "QR-Code herunterladen",
|
||||
"qrShare": "Link/QR teilen",
|
||||
"qrAlt": "QR-Code zur Gäste-Galerie",
|
||||
"allowDownloads": "Downloads erlauben",
|
||||
"allowDownloadsHint": "Gäste dürfen Fotos speichern",
|
||||
"allowSharing": "Teilen erlauben",
|
||||
"allowSharingHint": "Gäste dürfen Links teilen",
|
||||
"galleryOpen": "Galerie geöffnet",
|
||||
"galleryClosed": "Galerie geschlossen",
|
||||
"exportTitle": "Export & Backup",
|
||||
"exportCopy": "Alle Assets sichern",
|
||||
"exportHint": "Zip/CSV Export und Backup anstoßen.",
|
||||
"backup": "Backup",
|
||||
"downloadAll": "Alles herunterladen",
|
||||
"downloadHighlights": "Highlights herunterladen",
|
||||
"highlightsHint": "„Highlights“ = als Highlight markierte Fotos in der Galerie.",
|
||||
"retentionTitle": "Verlängerung / Archivierung",
|
||||
"expiresAt": "Läuft ab am {{date}}",
|
||||
"noExpiry": "Ablaufdatum nicht gesetzt",
|
||||
"retentionHint": "Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.",
|
||||
"expiry": "Ablauf",
|
||||
"archive": "Archivieren/Löschen",
|
||||
"extendOptions": "Alle Add-ons für dieses Event",
|
||||
"extendHint": "Verlängerungen addieren sich. Checkout öffnet in einem neuen Tab.",
|
||||
"priceMissing": "Preis nicht verknüpft",
|
||||
"noAddons": "Aktuell keine Add-ons verfügbar.",
|
||||
"archivedSuccess": "Event archiviert. Galerie ist geschlossen.",
|
||||
"archiveTitle": "Galerie archivieren?",
|
||||
"archiveDesc": "Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte vorher abschließen.",
|
||||
"archiveImpact": "Auswirkungen des Archivierens",
|
||||
"archiveImpactClose": "Gäste-Zugriff endet; Uploads/Downloads werden deaktiviert.",
|
||||
"archiveImpactLinks": "Öffentliche Links und QR-Codes werden ungültig; Sessions laufen aus.",
|
||||
"archiveImpactData": "Daten bleiben intern für Compliance/Support und können auf Anfrage gelöscht werden (DSGVO).",
|
||||
"archiveConfirm": "Ich habe Exporte abgeschlossen und möchte jetzt archivieren.",
|
||||
"archiveConfirmCta": "Jetzt archivieren"
|
||||
},
|
||||
"branding": {
|
||||
"badge": "Branding & Story",
|
||||
@@ -891,15 +1001,114 @@
|
||||
"feedback": {
|
||||
"title": "Wie läuft dein Event?",
|
||||
"subtitle": "Feedback hilft uns, neue Features zu priorisieren.",
|
||||
"positive": "Super Lauf!",
|
||||
"neutral": "Läuft",
|
||||
"negative": "Braucht Support",
|
||||
"afterEventTitle": "Event beendet – kurzes Feedback?",
|
||||
"afterEventCopy": "Hat alles geklappt? Deine Antwort hilft uns für kommende Events.",
|
||||
"privacyHint": "Nur Admin-Feedback, keine Gastdaten",
|
||||
"positive": "War super",
|
||||
"neutral": "In Ordnung",
|
||||
"negative": "Brauch(t)e Unterstützung",
|
||||
"best": {
|
||||
"uploads": "Uploads & Geschwindigkeit",
|
||||
"invites": "QR-Einladungen & Layouts",
|
||||
"moderation": "Moderation & Export",
|
||||
"experience": "Allgemeine App-Erfahrung"
|
||||
},
|
||||
"placeholder": "Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.",
|
||||
"errorTitle": "Feedback konnte nicht gesendet werden.",
|
||||
"authError": "Deine Session ist abgelaufen. Bitte melde dich erneut an.",
|
||||
"genericError": "Feedback konnte nicht gesendet werden.",
|
||||
"submit": "Feedback senden",
|
||||
"submitted": "Danke!"
|
||||
"submitted": "Danke!",
|
||||
"afterEventThanks": "Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.",
|
||||
"sendAnother": "Weiteres Feedback senden",
|
||||
"supportFollowup": "Support anfragen",
|
||||
"cta": "Feedback geben",
|
||||
"quickSentiment": "Stimmung auswählbar (positiv/neutral/Support).",
|
||||
"dialogTitle": "Kurzes After-Event Feedback",
|
||||
"dialogCopy": "Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.",
|
||||
"sentiment": "Stimmung",
|
||||
"bestQuestion": "Was lief am besten?",
|
||||
"improve": "Was sollen wir verbessern?",
|
||||
"supportHelp": "Ich hätte gern ein kurzes Follow-up (Support)."
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"actions": {
|
||||
"back": "Zurück zur Übersicht",
|
||||
"assign": "Ausgewählte Tasks zuweisen"
|
||||
},
|
||||
"title": "Aufgaben & Missionen",
|
||||
"subtitle": "Stelle Mission Cards und Aufgaben für dieses Event zusammen.",
|
||||
"alerts": {
|
||||
"notFoundTitle": "Event nicht gefunden",
|
||||
"notFoundDescription": "Bitte kehre zur Eventliste zurück."
|
||||
},
|
||||
"tabs": {
|
||||
"tasks": "Aufgaben",
|
||||
"packs": "Mission Packs"
|
||||
},
|
||||
"eventStatus": "Status: {{status}}",
|
||||
"modes": {
|
||||
"title": "Aufgaben & Foto-Modus",
|
||||
"tasksHint": "Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.",
|
||||
"photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.",
|
||||
"tasks": "Aufgaben aktiv",
|
||||
"photoOnly": "Foto-Modus",
|
||||
"switchLabel": "Aufgaben aktivieren/deaktivieren",
|
||||
"updating": "Einstellung wird gespeichert ..."
|
||||
},
|
||||
"summary": {
|
||||
"assigned": "Zugeordnete Tasks",
|
||||
"library": "Bibliothek",
|
||||
"mode": "Aktiver Modus",
|
||||
"tasksMode": "Mission Cards",
|
||||
"photoOnly": "Nur Fotos"
|
||||
},
|
||||
"library": {
|
||||
"hintTitle": "Weitere Vorlagen in der Aufgaben-Bibliothek",
|
||||
"hintCopy": "Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.",
|
||||
"open": "Aufgaben-Bibliothek öffnen"
|
||||
},
|
||||
"sections": {
|
||||
"assigned": {
|
||||
"title": "Zugeordnete Tasks",
|
||||
"search": "Aufgaben suchen...",
|
||||
"noResults": "Keine Aufgaben zum Suchbegriff.",
|
||||
"empty": "Noch keine Tasks zugewiesen."
|
||||
},
|
||||
"library": {
|
||||
"title": "Tasks aus Bibliothek hinzufügen",
|
||||
"empty": "Keine Tasks in der Bibliothek gefunden."
|
||||
}
|
||||
},
|
||||
"actionsShort": {
|
||||
"assign": "Ausgewählte Tasks zuweisen"
|
||||
},
|
||||
"errors": {
|
||||
"missingSlug": "Kein Event-Slug angegeben.",
|
||||
"load": "Event-Tasks konnten nicht geladen werden.",
|
||||
"assign": "Tasks konnten nicht zugewiesen werden.",
|
||||
"collections": "Kollektionen konnten nicht geladen werden.",
|
||||
"photoOnlyEnable": "Foto-Modus konnte nicht aktiviert werden.",
|
||||
"photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden."
|
||||
},
|
||||
"collections": {
|
||||
"errorTitle": "Kollektionen nicht verfügbar",
|
||||
"import": "Kollektion importieren",
|
||||
"error": "Kollektionen konnten nicht geladen werden.",
|
||||
"title": "Mission Packs",
|
||||
"subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.",
|
||||
"viewAll": "Alle Kollektionen ansehen",
|
||||
"empty": "Keine empfohlenen Kollektionen gefunden.",
|
||||
"tasksCount": "{{count}} Aufgaben",
|
||||
"genericType": "Allgemein",
|
||||
"global": "Global",
|
||||
"custom": "Custom",
|
||||
"recommended": "Empfohlen",
|
||||
"optional": "Optional",
|
||||
"importCta": "Mission Pack importieren",
|
||||
"imported": "Kollektion erfolgreich importiert",
|
||||
"importFailed": "Mission Pack konnte nicht importiert werden"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
@@ -1188,5 +1397,236 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding & Fonts",
|
||||
"subtitle": "Passe Farben, Typografie, Logos/Emojis und Buttons für die Gäste-App an.",
|
||||
"errors": {
|
||||
"missingSlug": "Kein Event ausgewählt – öffne es über die Eventliste."
|
||||
},
|
||||
"actions": {
|
||||
"back": "Zurück zum Event"
|
||||
},
|
||||
"sections": {
|
||||
"mode": "Standard vs. Event-spezifisch",
|
||||
"toggleTitle": "Branding-Quelle wählen",
|
||||
"toggleDescription": "Nutze das Standard-Branding des Tenants oder überschreibe es nur für dieses Event.",
|
||||
"palette": "Palette & Modus",
|
||||
"colorsTitle": "Farben & Light/Dark",
|
||||
"colorsDescription": "Primär-, Sekundär-, Hintergrund- und Surface-Farbe festlegen.",
|
||||
"typography": "Typografie & Logo",
|
||||
"fonts": "Schriften & Logo/Emoticon",
|
||||
"fontDescription": "Heading- und Body-Font plus Logo/Emoji und Ausrichtung festlegen.",
|
||||
"buttons": "Buttons & Links",
|
||||
"buttonsTitle": "Buttons, Links & Radius",
|
||||
"buttonsDescription": "Stil, Radius und optionale Link-Farbe wählen.",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Mini-Gastansicht",
|
||||
"previewCopy": "Header, CTA und Bottom-Navigation nach Branding visualisiert."
|
||||
},
|
||||
"useDefault": "Standard nutzen",
|
||||
"useCustom": "Event-spezifisch",
|
||||
"toggleHint": "Standard übernimmt die Tenant-Farben, Event-spezifisch überschreibt sie.",
|
||||
"standard": "Standard",
|
||||
"custom": "Event",
|
||||
"toggleAria": "Event-spezifisches Branding aktivieren",
|
||||
"mode": "Modus",
|
||||
"modeAuto": "Auto",
|
||||
"modeLight": "Hell",
|
||||
"modeDark": "Dunkel",
|
||||
"typography": {
|
||||
"heading": "Heading-Font",
|
||||
"body": "Body-Font"
|
||||
},
|
||||
"size": "Schriftgröße",
|
||||
"logo": {
|
||||
"value": "Emoticon/Logo-URL",
|
||||
"mode": "Logo-Modus",
|
||||
"position": "Position"
|
||||
},
|
||||
"emoticon": "Emoticon/Text",
|
||||
"upload": "Upload/URL",
|
||||
"left": "Links",
|
||||
"center": "Zentriert",
|
||||
"right": "Rechts",
|
||||
"palette": {
|
||||
"primary": "Primär",
|
||||
"secondary": "Sekundär",
|
||||
"surface": "Surface"
|
||||
},
|
||||
"buttonStyle": "Stil",
|
||||
"buttons": {
|
||||
"style": "Stil",
|
||||
"radius": "Radius",
|
||||
"primary": "Button Primary",
|
||||
"secondary": "Button Secondary",
|
||||
"linkColor": "Link-Farbe"
|
||||
},
|
||||
"filled": "Filled",
|
||||
"outline": "Outline",
|
||||
"radius": "Radius",
|
||||
"linkColor": "Link-Farbe",
|
||||
"buttonPrimary": "Button Primary",
|
||||
"buttonSecondary": "Button Secondary",
|
||||
"reset": "Auf Standard zurücksetzen",
|
||||
"save": "Branding speichern",
|
||||
"saving": "Speichern...",
|
||||
"saved": "Branding gespeichert.",
|
||||
"saveError": "Branding konnte nicht gespeichert werden.",
|
||||
"footer": {
|
||||
"default": "Standard-Farben des Tenants aktiv.",
|
||||
"custom": "Event-spezifisches Branding aktiv."
|
||||
},
|
||||
"usingDefault": "Tenant-Branding aktiv",
|
||||
"usingCustom": "Event-Branding aktiv",
|
||||
"preview": {
|
||||
"demoTitle": "Demo-Event",
|
||||
"guestView": "Gastansicht · {{mode}}",
|
||||
"ctaCopy": "CTA und Buttons spiegeln den gewählten Stil wider.",
|
||||
"cta": "Fotos jetzt hochladen",
|
||||
"bottomNav": "Bottom-Navigation"
|
||||
}
|
||||
},
|
||||
"taskLibrary": {
|
||||
"titles": {
|
||||
"default": "Task-Bibliothek",
|
||||
"embedded": "Aufgaben"
|
||||
},
|
||||
"subtitles": {
|
||||
"default": "Weise Aufgaben zu und tracke den Fortschritt rund um deine Events.",
|
||||
"embedded": "Plane Aufgaben, Aktionen und Highlights für deine Gäste."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Fehler",
|
||||
"load": "Tasks konnten nicht geladen werden."
|
||||
},
|
||||
"actions": {
|
||||
"collections": "Collections",
|
||||
"new": "Neu",
|
||||
"searchPlaceholder": "Nach Aufgaben suchen …"
|
||||
},
|
||||
"pagination": {
|
||||
"page": "Seite {{current}} von {{total}} · {{count}} Einträge",
|
||||
"summary": "Insgesamt {{count}} Tasks · Seite {{current}} von {{total}}",
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter"
|
||||
},
|
||||
"form": {
|
||||
"editTitle": "Task bearbeiten",
|
||||
"createTitle": "Neue Task erstellen",
|
||||
"title": "Titel",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Was sollen Gäste machen?",
|
||||
"priority": "Priorität",
|
||||
"priorityPlaceholder": "Priorität wählen",
|
||||
"dueDate": "Fälligkeitsdatum",
|
||||
"completedTitle": "Bereits erledigt?",
|
||||
"completedCopy": "Markiere Aufgaben als abgeschlossen, wenn sie nicht mehr sichtbar sein sollen.",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"priorities": {
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch",
|
||||
"urgent": "Dringend"
|
||||
},
|
||||
"list": {
|
||||
"template": "Vorlage #{{id}}",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Noch keine Tasks angelegt",
|
||||
"description": "Starte mit einer neuen Aufgabe oder importiere Vorlagen, um deine Gäste zu inspirieren.",
|
||||
"cta": "Erste Task erstellen"
|
||||
}
|
||||
},
|
||||
"billingWarning": {
|
||||
"title": "Achtung",
|
||||
"description": "Paket-Hinweise und Limits, die du im Blick behalten solltest."
|
||||
},
|
||||
"eventForm": {
|
||||
"errors": {
|
||||
"nameRequired": "Bitte gib einen Eventnamen ein.",
|
||||
"typeRequired": "Bitte wähle einen Event-Typ aus."
|
||||
},
|
||||
"titles": {
|
||||
"create": "Neues Event erstellen",
|
||||
"edit": "Event bearbeiten"
|
||||
},
|
||||
"subtitle": "Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen.",
|
||||
"sections": {
|
||||
"details": {
|
||||
"title": "Eventdetails",
|
||||
"description": "Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"name": {
|
||||
"label": "Eventname",
|
||||
"placeholder": "z. B. Sommerfest 2025",
|
||||
"help": "Die Kennung und Event-URL werden automatisch aus dem Namen generiert."
|
||||
},
|
||||
"date": {
|
||||
"label": "Datum"
|
||||
},
|
||||
"type": {
|
||||
"label": "Event-Typ",
|
||||
"loading": "Event-Typ wird geladen…",
|
||||
"placeholder": "Event-Typ auswählen",
|
||||
"empty": "Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an."
|
||||
},
|
||||
"publish": {
|
||||
"label": "Event sofort veröffentlichen",
|
||||
"help": "Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"backToList": "Zurück zur Liste",
|
||||
"saving": "Speichert",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"errors": {
|
||||
"notice": "Hinweis"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"trigger": "Benachrichtigungen",
|
||||
"title": "Benachrichtigungen",
|
||||
"empty": "Aktuell ruhig",
|
||||
"empty.message": "Alles erledigt – wir melden uns bei Neuigkeiten.",
|
||||
"action": {
|
||||
"dismiss": "Ausblenden",
|
||||
"refresh": "Neue Hinweise laden"
|
||||
},
|
||||
"noEvents": {
|
||||
"title": "Lass uns starten",
|
||||
"description": "Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.",
|
||||
"cta": "Event erstellen"
|
||||
},
|
||||
"draftEvent": {
|
||||
"title": "Event noch als Entwurf",
|
||||
"description": "Veröffentliche das Event, um Einladungen und Galerie freizugeben.",
|
||||
"cta": "Event öffnen"
|
||||
},
|
||||
"upcomingEvent": {
|
||||
"title": "Event startet bald",
|
||||
"description_today": "Heute findet ein Event statt – checke Uploads und Tasks.",
|
||||
"description_days": "Noch {{count}} Tage – bereite Einladungen und Aufgaben vor.",
|
||||
"cta": "Zum Event"
|
||||
},
|
||||
"pendingUploads": {
|
||||
"title": "Uploads warten auf Freigabe",
|
||||
"description": "{{count}} neue Uploads benötigen Moderation.",
|
||||
"cta": "Uploads öffnen"
|
||||
},
|
||||
"newPhotos": {
|
||||
"title": "Neue Fotos eingetroffen",
|
||||
"description": "{{count}} Uploads warten auf dich.",
|
||||
"cta": "Galerie öffnen",
|
||||
"ctaFallback": "Events ansehen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,10 @@
|
||||
"return_hint": "After signing in you’ll be brought back automatically.",
|
||||
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
|
||||
"appearance_label": "Appearance"
|
||||
},
|
||||
"redirecting": "Redirecting to login …",
|
||||
"processing": {
|
||||
"title": "Signing you in …",
|
||||
"copy": "One moment please while we prepare your dashboard."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,9 +274,18 @@
|
||||
"untitled": "Untitled event"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Event tasks",
|
||||
"subtitle": "Manage tasks associated with this event.",
|
||||
"eventMenu": {
|
||||
"summary": "Overview",
|
||||
"photos": "Uploads",
|
||||
"tasks": "Tasks",
|
||||
"invites": "Invites",
|
||||
"branding": "Branding",
|
||||
"photobooth": "Photobooth",
|
||||
"recap": "Recap"
|
||||
},
|
||||
"eventTasks": {
|
||||
"title": "Tasks & missions",
|
||||
"subtitle": "Curate mission cards and tasks for this event.",
|
||||
"actions": {
|
||||
"back": "Back to overview",
|
||||
"assign": "Assign selected tasks"
|
||||
@@ -286,7 +295,8 @@
|
||||
"load": "Event tasks could not be loaded.",
|
||||
"assign": "Tasks could not be assigned.",
|
||||
"photoOnlyEnable": "Photo-only mode could not be enabled.",
|
||||
"photoOnlyDisable": "Photo-only mode could not be disabled."
|
||||
"photoOnlyDisable": "Photo-only mode could not be disabled.",
|
||||
"collections": "Collections could not be loaded."
|
||||
},
|
||||
"emotions": {
|
||||
"error": "Could not load emotions."
|
||||
@@ -295,10 +305,28 @@
|
||||
"notFoundTitle": "Event not found",
|
||||
"notFoundDescription": "Please return to the event list."
|
||||
},
|
||||
"tabs": {
|
||||
"tasks": "Tasks",
|
||||
"packs": "Mission packs"
|
||||
},
|
||||
"eventStatus": "Status: {{status}}",
|
||||
"summary": {
|
||||
"assigned": "Assigned tasks",
|
||||
"library": "Library",
|
||||
"mode": "Active mode",
|
||||
"tasksMode": "Mission cards",
|
||||
"photoOnly": "Photos only"
|
||||
},
|
||||
"library": {
|
||||
"hintTitle": "More templates in the task library",
|
||||
"hintCopy": "Create tasks, emotions, or mission packs once and reuse them across events.",
|
||||
"open": "Open task library"
|
||||
},
|
||||
"sections": {
|
||||
"assigned": {
|
||||
"title": "Assigned tasks",
|
||||
"search": "Search tasks...",
|
||||
"noResults": "No tasks match this search term.",
|
||||
"empty": "No tasks assigned yet."
|
||||
},
|
||||
"library": {
|
||||
@@ -321,6 +349,23 @@
|
||||
"switchLabel": "Enable photo-only mode",
|
||||
"updating": "Saving setting ..."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Mission packs",
|
||||
"subtitle": "Import task collections that fit your event.",
|
||||
"viewAll": "View all collections",
|
||||
"errorTitle": "Collections unavailable",
|
||||
"empty": "No recommended collections found.",
|
||||
"tasksCount": "{{count}} tasks",
|
||||
"genericType": "General",
|
||||
"global": "Global",
|
||||
"custom": "Custom",
|
||||
"recommended": "Recommended",
|
||||
"optional": "Optional",
|
||||
"importCta": "Import mission pack",
|
||||
"imported": "Collection imported successfully",
|
||||
"importFailed": "Mission pack could not be imported",
|
||||
"error": "Collections could not be loaded."
|
||||
},
|
||||
"toolkit": {
|
||||
"titleFallback": "Event-Day Toolkit",
|
||||
"subtitle": "Stay on top of uploads, tasks, and invites while your event is live.",
|
||||
@@ -768,6 +813,11 @@
|
||||
"workspace": {
|
||||
"detailSubtitle": "Keep status, tasks, and invites of your event in one view.",
|
||||
"toolkitSubtitle": "Bundle moderation, tasks, and invites for the event day.",
|
||||
"hero": {
|
||||
"badge": "Event",
|
||||
"description": "Focus on tasks, moderation, and invites for this event.",
|
||||
"liveBadge": "Live?"
|
||||
},
|
||||
"sections": {
|
||||
"statusTitle": "Event status & visibility",
|
||||
"statusSubtitle": "Activate the event for guests or hide it temporarily."
|
||||
@@ -776,6 +826,7 @@
|
||||
"status": "Status",
|
||||
"active": "Active for guests",
|
||||
"date": "Event date",
|
||||
"noDate": "No date",
|
||||
"eventType": "Event type",
|
||||
"insights": "Recent activity",
|
||||
"uploadsTotal": "{{count}} uploads total",
|
||||
@@ -828,6 +879,7 @@
|
||||
"activeInvites": "Active invites"
|
||||
},
|
||||
"invites": {
|
||||
"badge": "Invites",
|
||||
"title": "QR invites",
|
||||
"subtitle": "Keep an eye on active links and layouts.",
|
||||
"activeCount": "{{count}} active",
|
||||
@@ -836,11 +888,69 @@
|
||||
"manage": "Manage layouts & invites"
|
||||
},
|
||||
"tasks": {
|
||||
"badge": "Tasks",
|
||||
"title": "Active tasks",
|
||||
"subtitle": "Motivate guests with clear prompts & highlights.",
|
||||
"summary": "{{completed}} of {{total}} complete",
|
||||
"empty": "No tasks assigned yet.",
|
||||
"manage": "Open task workspace"
|
||||
"manage": "Open task workspace",
|
||||
"status": {
|
||||
"completed": "Done",
|
||||
"open": "Open"
|
||||
}
|
||||
},
|
||||
"recap": {
|
||||
"badge": "Recap",
|
||||
"subtitle": "Wrap up, export, and manage gallery runtime.",
|
||||
"galleryTitle": "Gallery status",
|
||||
"galleryCounts": "{{photos}} photos, {{pending}} pending, {{likes}} likes",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"openGallery": "Open gallery",
|
||||
"closeGallery": "Close gallery",
|
||||
"moderate": "View uploads",
|
||||
"shareGuests": "Share guest gallery",
|
||||
"shareLink": "Guest link",
|
||||
"noPublicUrl": "No guest link set. Configure the public link in the event setup.",
|
||||
"copyLink": "Copy link",
|
||||
"copySuccess": "Link copied",
|
||||
"copyError": "Link could not be shared.",
|
||||
"qrTitle": "Share QR code",
|
||||
"qrDownload": "Download QR code",
|
||||
"qrShare": "Share link/QR",
|
||||
"qrAlt": "Guest gallery QR code",
|
||||
"allowDownloads": "Allow downloads",
|
||||
"allowDownloadsHint": "Guests may save photos",
|
||||
"allowSharing": "Allow sharing",
|
||||
"allowSharingHint": "Guests may share links",
|
||||
"galleryOpen": "Gallery open",
|
||||
"galleryClosed": "Gallery closed",
|
||||
"exportTitle": "Export & backup",
|
||||
"exportCopy": "Back up all assets",
|
||||
"exportHint": "Start ZIP/CSV exports and backups.",
|
||||
"backup": "Backup",
|
||||
"downloadAll": "Download everything",
|
||||
"downloadHighlights": "Download highlights",
|
||||
"highlightsHint": "“Highlights” = photos marked as highlight in the gallery.",
|
||||
"retentionTitle": "Extend / archive",
|
||||
"expiresAt": "Expires on {{date}}",
|
||||
"noExpiry": "No expiry date set",
|
||||
"retentionHint": "Extend the gallery runtime with an add-on. Extensions add up.",
|
||||
"expiry": "Expiry",
|
||||
"archive": "Archive/Delete",
|
||||
"extendOptions": "All add-ons for this event",
|
||||
"extendHint": "Extensions add up. Checkout opens in a new tab.",
|
||||
"priceMissing": "Price not linked",
|
||||
"noAddons": "No add-ons available right now.",
|
||||
"archivedSuccess": "Event archived. Gallery is closed.",
|
||||
"archiveTitle": "Archive gallery?",
|
||||
"archiveDesc": "Archiving closes the gallery, deactivates guest links, and stops new uploads. Finish exports first.",
|
||||
"archiveImpact": "Effects of archiving",
|
||||
"archiveImpactClose": "Guest access ends; uploads/downloads are disabled.",
|
||||
"archiveImpactLinks": "Public links and QR codes become invalid; existing sessions expire.",
|
||||
"archiveImpactData": "Data stays internally for compliance/support and can be deleted on request (GDPR).",
|
||||
"archiveConfirm": "I completed exports and want to archive now.",
|
||||
"archiveConfirmCta": "Archive now"
|
||||
},
|
||||
"branding": {
|
||||
"badge": "Branding & story",
|
||||
@@ -887,15 +997,114 @@
|
||||
"feedback": {
|
||||
"title": "How is your event running?",
|
||||
"subtitle": "Your feedback helps us prioritise improvements.",
|
||||
"positive": "Going great!",
|
||||
"neutral": "All right",
|
||||
"negative": "Needs support",
|
||||
"placeholder": "Optional: tell us what works well or where you need help.",
|
||||
"afterEventTitle": "Event wrapped – quick feedback?",
|
||||
"afterEventCopy": "Did everything work out? Your answer helps us support future events.",
|
||||
"privacyHint": "Admin-only feedback, no guest data",
|
||||
"positive": "Was great",
|
||||
"neutral": "Was okay",
|
||||
"negative": "Needed support",
|
||||
"best": {
|
||||
"uploads": "Uploads & speed",
|
||||
"invites": "QR invites & layouts",
|
||||
"moderation": "Moderation & export",
|
||||
"experience": "Overall app experience"
|
||||
},
|
||||
"placeholder": "Optional: tell us what worked well or what to improve.",
|
||||
"errorTitle": "Feedback could not be sent.",
|
||||
"authError": "Your session expired. Please sign in again.",
|
||||
"genericError": "Feedback could not be sent.",
|
||||
"submit": "Send feedback",
|
||||
"submitted": "Thanks!"
|
||||
"submitted": "Thanks!",
|
||||
"afterEventThanks": "Your feedback arrived. We’ll get back to you if we have questions.",
|
||||
"sendAnother": "Send another feedback",
|
||||
"supportFollowup": "Request support",
|
||||
"cta": "Give feedback",
|
||||
"quickSentiment": "Pick sentiment (positive/neutral/support).",
|
||||
"dialogTitle": "Quick after-event feedback",
|
||||
"dialogCopy": "Pick a sentiment, what worked best, and optionally what to improve.",
|
||||
"sentiment": "Sentiment",
|
||||
"bestQuestion": "What worked best?",
|
||||
"improve": "What should we improve?",
|
||||
"supportHelp": "I’d like a short follow-up (support)."
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"actions": {
|
||||
"back": "Back to overview",
|
||||
"assign": "Assign selected tasks"
|
||||
},
|
||||
"title": "Tasks & missions",
|
||||
"subtitle": "Curate mission cards and tasks for this event.",
|
||||
"alerts": {
|
||||
"notFoundTitle": "Event not found",
|
||||
"notFoundDescription": "Please return to the event list."
|
||||
},
|
||||
"tabs": {
|
||||
"tasks": "Tasks",
|
||||
"packs": "Mission packs"
|
||||
},
|
||||
"eventStatus": "Status: {{status}}",
|
||||
"modes": {
|
||||
"title": "Tasks & photo mode",
|
||||
"tasksHint": "Tasks are active. Guests see mission cards in the app.",
|
||||
"photoOnlyHint": "Photo-only mode is active. Guests can upload photos but won’t see tasks.",
|
||||
"tasks": "Tasks active",
|
||||
"photoOnly": "Photo-only",
|
||||
"switchLabel": "Enable/disable tasks",
|
||||
"updating": "Saving setting ..."
|
||||
},
|
||||
"summary": {
|
||||
"assigned": "Assigned tasks",
|
||||
"library": "Library",
|
||||
"mode": "Active mode",
|
||||
"tasksMode": "Mission cards",
|
||||
"photoOnly": "Photos only"
|
||||
},
|
||||
"library": {
|
||||
"hintTitle": "More templates in the task library",
|
||||
"hintCopy": "Create tasks, emotions, or mission packs once and reuse them across events.",
|
||||
"open": "Open task library"
|
||||
},
|
||||
"sections": {
|
||||
"assigned": {
|
||||
"title": "Assigned tasks",
|
||||
"search": "Search tasks...",
|
||||
"noResults": "No tasks match this search term.",
|
||||
"empty": "No tasks assigned yet."
|
||||
},
|
||||
"library": {
|
||||
"title": "Add tasks from library",
|
||||
"empty": "No tasks found in the library."
|
||||
}
|
||||
},
|
||||
"actionsShort": {
|
||||
"assign": "Assign selected tasks"
|
||||
},
|
||||
"errors": {
|
||||
"missingSlug": "No event slug provided.",
|
||||
"load": "Tasks could not be loaded.",
|
||||
"assign": "Tasks could not be assigned.",
|
||||
"collections": "Collections could not be loaded.",
|
||||
"photoOnlyEnable": "Photo-only mode could not be enabled.",
|
||||
"photoOnlyDisable": "Photo-only mode could not be disabled."
|
||||
},
|
||||
"collections": {
|
||||
"errorTitle": "Collections unavailable",
|
||||
"import": "Import collection",
|
||||
"error": "Collections could not be loaded.",
|
||||
"title": "Mission packs",
|
||||
"subtitle": "Import task collections that fit your event.",
|
||||
"viewAll": "View all collections",
|
||||
"empty": "No recommended collections found.",
|
||||
"tasksCount": "{{count}} tasks",
|
||||
"genericType": "General",
|
||||
"global": "Global",
|
||||
"custom": "Custom",
|
||||
"recommended": "Recommended",
|
||||
"optional": "Optional",
|
||||
"importCta": "Import mission pack",
|
||||
"imported": "Collection imported successfully",
|
||||
"importFailed": "Mission pack could not be imported"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
@@ -1185,8 +1394,235 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding & fonts",
|
||||
"subtitle": "Adjust colors, typography, logos/emoticons, and buttons for the guest app.",
|
||||
"errors": {
|
||||
"missingSlug": "No event selected – open it from the event list."
|
||||
},
|
||||
"actions": {
|
||||
"back": "Back to event"
|
||||
},
|
||||
"sections": {
|
||||
"mode": "Default vs. event-specific",
|
||||
"toggleTitle": "Choose branding source",
|
||||
"toggleDescription": "Use tenant defaults or override only for this event.",
|
||||
"palette": "Palette & mode",
|
||||
"colorsTitle": "Colors & light/dark",
|
||||
"colorsDescription": "Set primary, secondary, background, and surface colors.",
|
||||
"typography": "Typography & logo",
|
||||
"fonts": "Fonts & logo/emoticon",
|
||||
"fontDescription": "Configure heading/body fonts plus logo/emoji and alignment.",
|
||||
"buttons": "Buttons & links",
|
||||
"buttonsTitle": "Buttons, links & radius",
|
||||
"buttonsDescription": "Choose style, radius, and optional link color.",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Mini guest view",
|
||||
"previewCopy": "Header, CTA, and bottom navigation reflecting your branding."
|
||||
},
|
||||
"useDefault": "Use default",
|
||||
"useCustom": "Event-specific",
|
||||
"toggleHint": "Default uses tenant colors; event-specific overrides them.",
|
||||
"standard": "Default",
|
||||
"custom": "Event",
|
||||
"toggleAria": "Toggle event-specific branding",
|
||||
"mode": "Mode",
|
||||
"modeAuto": "Auto",
|
||||
"modeLight": "Light",
|
||||
"modeDark": "Dark",
|
||||
"typography": {
|
||||
"heading": "Heading font",
|
||||
"body": "Body font"
|
||||
},
|
||||
"size": "Font size",
|
||||
"logo": {
|
||||
"value": "Emoticon/Logo URL",
|
||||
"mode": "Logo mode",
|
||||
"position": "Position"
|
||||
},
|
||||
"emoticon": "Emoticon/Text",
|
||||
"upload": "Upload/URL",
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"palette": {
|
||||
"primary": "Primary",
|
||||
"secondary": "Secondary",
|
||||
"surface": "Surface"
|
||||
},
|
||||
"buttonStyle": "Style",
|
||||
"buttons": {
|
||||
"style": "Style",
|
||||
"radius": "Radius",
|
||||
"primary": "Button primary",
|
||||
"secondary": "Button secondary",
|
||||
"linkColor": "Link color"
|
||||
},
|
||||
"filled": "Filled",
|
||||
"outline": "Outline",
|
||||
"radius": "Radius",
|
||||
"linkColor": "Link color",
|
||||
"buttonPrimary": "Button primary",
|
||||
"buttonSecondary": "Button secondary",
|
||||
"reset": "Reset to default",
|
||||
"save": "Save branding",
|
||||
"saving": "Saving...",
|
||||
"saved": "Branding saved.",
|
||||
"saveError": "Branding could not be saved.",
|
||||
"footer": {
|
||||
"default": "Tenant default colors active.",
|
||||
"custom": "Event-specific branding active."
|
||||
},
|
||||
"usingDefault": "Tenant branding active",
|
||||
"usingCustom": "Event branding active",
|
||||
"preview": {
|
||||
"demoTitle": "Demo event",
|
||||
"guestView": "Guest view · {{mode}}",
|
||||
"ctaCopy": "CTA & buttons reflect the chosen style.",
|
||||
"cta": "Upload photos now",
|
||||
"bottomNav": "Bottom navigation"
|
||||
}
|
||||
},
|
||||
"taskLibrary": {
|
||||
"titles": {
|
||||
"default": "Task library",
|
||||
"embedded": "Tasks"
|
||||
},
|
||||
"subtitles": {
|
||||
"default": "Assign tasks and track progress around your events.",
|
||||
"embedded": "Plan tasks, actions, and highlights for your guests."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Error",
|
||||
"load": "Tasks could not be loaded."
|
||||
},
|
||||
"actions": {
|
||||
"collections": "Collections",
|
||||
"new": "New",
|
||||
"searchPlaceholder": "Search tasks …"
|
||||
},
|
||||
"pagination": {
|
||||
"page": "Page {{current}} of {{total}} · {{count}} entries",
|
||||
"summary": "Total {{count}} tasks · Page {{current}} of {{total}}",
|
||||
"prev": "Back",
|
||||
"next": "Next"
|
||||
},
|
||||
"form": {
|
||||
"editTitle": "Edit task",
|
||||
"createTitle": "Create task",
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What should guests do?",
|
||||
"priority": "Priority",
|
||||
"priorityPlaceholder": "Choose priority",
|
||||
"dueDate": "Due date",
|
||||
"completedTitle": "Already completed?",
|
||||
"completedCopy": "Mark tasks as done when they should no longer be visible.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
},
|
||||
"priorities": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"urgent": "Urgent"
|
||||
},
|
||||
"list": {
|
||||
"template": "Template #{{id}}",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No tasks yet",
|
||||
"description": "Create a new task or import templates to inspire your guests.",
|
||||
"cta": "Create first task"
|
||||
}
|
||||
},
|
||||
"billingWarning": {
|
||||
"title": "Needs attention",
|
||||
"description": "Package alerts and limits you should keep an eye on."
|
||||
},
|
||||
"eventForm": {
|
||||
"errors": {
|
||||
"nameRequired": "Please enter an event name.",
|
||||
"typeRequired": "Please select an event type."
|
||||
},
|
||||
"titles": {
|
||||
"create": "Create event",
|
||||
"edit": "Edit event"
|
||||
},
|
||||
"subtitle": "Fill in the essentials and share your event with guests.",
|
||||
"sections": {
|
||||
"details": {
|
||||
"title": "Event details",
|
||||
"description": "Name, URL, and date define how guests see your event."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"name": {
|
||||
"label": "Event name",
|
||||
"placeholder": "e.g. Summer Party 2025",
|
||||
"help": "The slug and event URL are generated from the name."
|
||||
},
|
||||
"date": {
|
||||
"label": "Date"
|
||||
},
|
||||
"type": {
|
||||
"label": "Event type",
|
||||
"loading": "Loading event types…",
|
||||
"placeholder": "Select event type",
|
||||
"empty": "No event types available yet. Please add one in the admin area."
|
||||
},
|
||||
"publish": {
|
||||
"label": "Publish immediately",
|
||||
"help": "Enable if guests should see the event right away. You can change the status later."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"backToList": "Back to list",
|
||||
"saving": "Saving",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"errors": {
|
||||
"notice": "Notice"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"trigger": "Notifications",
|
||||
"title": "Notifications",
|
||||
"empty": "All clear",
|
||||
"empty.message": "All caught up — we’ll notify you about updates.",
|
||||
"action": {
|
||||
"dismiss": "Dismiss",
|
||||
"refresh": "Load new alerts"
|
||||
},
|
||||
"noEvents": {
|
||||
"title": "Let’s get started",
|
||||
"description": "Create your first event to unlock uploads, tasks, and invites.",
|
||||
"cta": "Create event"
|
||||
},
|
||||
"draftEvent": {
|
||||
"title": "Event still a draft",
|
||||
"description": "Publish to enable invites and the gallery.",
|
||||
"cta": "Open event"
|
||||
},
|
||||
"upcomingEvent": {
|
||||
"title": "Event starts soon",
|
||||
"description_today": "Today’s event — check uploads and tasks.",
|
||||
"description_days": "{{count}} days left — prepare invites and tasks.",
|
||||
"cta": "Open event"
|
||||
},
|
||||
"pendingUploads": {
|
||||
"title": "Uploads awaiting review",
|
||||
"description": "{{count}} new uploads need moderation.",
|
||||
"cta": "Open uploads"
|
||||
},
|
||||
"newPhotos": {
|
||||
"title": "New photos arrived",
|
||||
"description": "{{count}} uploads are waiting for you.",
|
||||
"cta": "Open gallery",
|
||||
"ctaFallback": "View events"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventDate = event.event_date ? new Date(event.event_date) : null;
|
||||
const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false;
|
||||
|
||||
const formatBadge = (value?: number | null): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -63,10 +66,12 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
|
||||
label: translate('eventMenu.photobooth', 'Photobooth'),
|
||||
href: ADMIN_EVENT_PHOTOBOOTH_PATH(event.slug),
|
||||
},
|
||||
{
|
||||
key: 'recap',
|
||||
label: translate('eventMenu.recap', 'Nachbereitung'),
|
||||
href: ADMIN_EVENT_RECAP_PATH(event.slug),
|
||||
},
|
||||
...(hasPassed
|
||||
? [{
|
||||
key: 'recap',
|
||||
label: translate('eventMenu.recap', 'Nachbereitung'),
|
||||
href: ADMIN_EVENT_RECAP_PATH(event.slug),
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,15 @@ vi.mock('../../components/LanguageSwitcher', () => ({
|
||||
LanguageSwitcher: () => <div data-testid="language-switcher" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/context', () => ({
|
||||
useAuth: () => ({ status: 'authenticated', user: { name: 'Test User' } }),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
fetchOnboardingStatus: vi.fn().mockResolvedValue(null),
|
||||
trackOnboarding: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('WelcomeLandingPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
@@ -5,6 +5,10 @@ import '@testing-library/jest-dom';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
|
||||
|
||||
vi.mock('../../auth/context', () => ({
|
||||
useAuth: () => ({ status: 'authenticated', user: { name: 'Test User' } }),
|
||||
}));
|
||||
|
||||
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
|
||||
createPaddleCheckoutMock: vi.fn(),
|
||||
}));
|
||||
|
||||
55
resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx
Normal file
55
resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_WELCOME_PACKAGES_PATH } from '../../constants';
|
||||
|
||||
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
|
||||
|
||||
export default function WelcomeLandingPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const prev = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
|
||||
const next = { ...prev, welcomeSeen: true, lastStep: 'landing' };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist onboarding progress', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6" data-testid="welcome-landing">
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">Tenant Admin Onboarding</h1>
|
||||
<p className="text-sm text-slate-600">Starte mit der Einrichtung deines Workspaces.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-slate-900 px-4 py-2 text-white"
|
||||
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
|
||||
>
|
||||
hero.primary.label
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-slate-700"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
>
|
||||
hero.secondary.label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-slate-600 underline"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
>
|
||||
layout.jumpToDashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { createTenantPaddleCheckout } from '../../api';
|
||||
|
||||
type PaddleCheckoutProps = {
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
export function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const checkout = await createTenantPaddleCheckout(packageId);
|
||||
if (checkout?.checkout_url) {
|
||||
window.open(checkout.checkout_url, '_blank', 'noopener');
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t('errors.generic', 'Fehler');
|
||||
setError(message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{error ? (
|
||||
<div role="alert" className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center rounded-md bg-slate-900 px-4 py-2 text-white disabled:opacity-70"
|
||||
>
|
||||
{busy ? t('welcome.packages.loading', 'Lädt…') : t('welcome.packages.purchase', 'Jetzt kaufen')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WelcomeOrderSummaryPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p>Order summary placeholder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
@@ -7,6 +8,7 @@ import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
export default function AuthCallbackPage(): React.ReactElement {
|
||||
const { status } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('auth');
|
||||
const [redirected, setRedirected] = React.useState(false);
|
||||
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
|
||||
@@ -35,10 +37,8 @@ export default function AuthCallbackPage(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet …</span>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
Bitte warte einen Moment. Wir richten dein Dashboard ein.
|
||||
</p>
|
||||
<span className="text-base font-medium text-foreground">{t('processing.title', 'Signing you in …')}</span>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -681,6 +681,7 @@ export default function EventBrandingPage(): React.ReactElement {
|
||||
}
|
||||
|
||||
function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: 'light' | 'dark' }) {
|
||||
const { t } = useTranslation('management');
|
||||
const textColor = getContrastingTextColor(branding.palette.primary, '#0f172a', '#ffffff');
|
||||
const headerStyle: React.CSSProperties = {
|
||||
background: `linear-gradient(135deg, ${branding.palette.primary}, ${branding.palette.secondary})`,
|
||||
@@ -709,9 +710,11 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>
|
||||
Demo Event
|
||||
{t('branding.preview.demoTitle', 'Demo Event')}
|
||||
</CardTitle>
|
||||
<span className="text-xs opacity-80">Gastansicht · {branding.mode}</span>
|
||||
<span className="text-xs opacity-80">
|
||||
{t('branding.preview.guestView', { mode: branding.mode, defaultValue: 'Guest view · {{mode}}' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -719,7 +722,7 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
|
||||
<CardContent className="space-y-4 bg-[var(--surface)] px-4 py-5" style={{ ['--surface' as string]: branding.palette.surface }}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-200" style={{ fontFamily: branding.typography.body || undefined }}>
|
||||
CTA & Buttons spiegeln den gewählten Stil wider.
|
||||
{t('branding.preview.ctaCopy', 'CTA & buttons reflect the chosen style.')}
|
||||
</p>
|
||||
<Button
|
||||
className="shadow-md transition"
|
||||
@@ -730,12 +733,14 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
|
||||
paddingBlock: '10px',
|
||||
}}
|
||||
>
|
||||
Jetzt Fotos hochladen
|
||||
{t('branding.preview.cta', 'Upload photos now')}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between rounded-2xl border border-dashed border-slate-200 p-3 text-sm dark:border-slate-700" style={{ borderRadius: branding.buttons.radius }}>
|
||||
<span style={{ color: branding.buttons.linkColor || branding.palette.secondary }}>Bottom Navigation</span>
|
||||
<span style={{ color: branding.buttons.linkColor || branding.palette.secondary }}>
|
||||
{t('branding.preview.bottomNav', 'Bottom navigation')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.primary }} />
|
||||
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.secondary }} />
|
||||
|
||||
@@ -59,6 +59,8 @@ import {
|
||||
ADMIN_EVENT_BRANDING_PATH,
|
||||
buildEngagementTabPath,
|
||||
} from '../constants';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
import { formatEventDate } from '../lib/events';
|
||||
import {
|
||||
SectionCard,
|
||||
SectionHeader,
|
||||
@@ -207,6 +209,18 @@ export default function EventDetailPage() {
|
||||
|
||||
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||
const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
|
||||
const currentTabKey = 'overview';
|
||||
|
||||
const eventTabs = React.useMemo(() => {
|
||||
if (!event) return [];
|
||||
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const counts = {
|
||||
photos: stats?.uploads_total ?? event.photo_count ?? undefined,
|
||||
tasks: toolkitData?.tasks?.summary?.total ?? event.tasks_count ?? undefined,
|
||||
invites: event.active_invites_count ?? event.total_invites_count ?? undefined,
|
||||
};
|
||||
return buildEventTabs(event, translateMenu, counts);
|
||||
}, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]);
|
||||
|
||||
const limitWarnings = React.useMemo(
|
||||
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
|
||||
@@ -352,6 +366,8 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||||
<AdminLayout
|
||||
title={eventName}
|
||||
subtitle={subtitle}
|
||||
tabs={eventTabs}
|
||||
currentTabKey={currentTabKey}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
@@ -538,6 +554,159 @@ function resolveName(name: TenantEvent['name']): string {
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
type RecapContentProps = {
|
||||
event: TenantEvent;
|
||||
stats: EventStats;
|
||||
busy: boolean;
|
||||
onToggleEvent: () => void;
|
||||
onExtendGallery: () => void;
|
||||
};
|
||||
|
||||
function RecapContent({ event, stats, busy, onToggleEvent, onExtendGallery }: RecapContentProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null;
|
||||
const galleryStatusLabel = event.is_active
|
||||
? t('events.recap.galleryOpen', 'Galerie geöffnet')
|
||||
: t('events.recap.galleryClosed', 'Galerie geschlossen');
|
||||
|
||||
const counts = {
|
||||
photos: stats.uploads_total ?? stats.total ?? 0,
|
||||
pending: stats.pending_photos ?? 0,
|
||||
likes: stats.likes_total ?? stats.likes ?? 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-emerald-500">
|
||||
{t('events.recap.galleryTitle', 'Galerie-Status')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{galleryStatusLabel}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={event.is_active ? 'default' : 'outline'} className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-100">
|
||||
{event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant={event.is_active ? 'secondary' : 'default'} disabled={busy} onClick={onToggleEvent}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
{t('events.recap.moderate', 'Uploads ansehen')}
|
||||
</Button>
|
||||
</div>
|
||||
{event.public_url ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<span className="truncate" title={event.public_url}>{event.public_url}</span>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => navigator.clipboard.writeText(event.public_url!)}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-500">
|
||||
{t('events.recap.exportTitle', 'Export & Backup')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('events.recap.exportCopy', 'Alle Assets sichern')}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-indigo-200 text-indigo-700 dark:border-indigo-800 dark:text-indigo-200">
|
||||
{t('events.recap.backup', 'Backup')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4 rotate-180" />
|
||||
{t('events.recap.exportAll', 'Alles exportieren')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
|
||||
<Printer className="mr-2 h-4 w-4" />
|
||||
{t('events.recap.exportHighlights', 'Highlights exportieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">
|
||||
{t('events.recap.retentionTitle', 'Aufbewahrung & Verlängerung')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{galleryExpiresAt
|
||||
? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
|
||||
: t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-100">
|
||||
{t('events.recap.expiry', 'Ablauf')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="default" onClick={onExtendGallery}>
|
||||
<Clock3 className="mr-2 h-4 w-4" />
|
||||
{t('events.recap.extend30', '+30 Tage verlängern')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}>
|
||||
{t('events.recap.archive', 'Archivieren/Löschen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-rose-500">
|
||||
{t('events.recap.commsTitle', 'Kommunikation')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('events.recap.commsCopy', 'Kein Gast-Newsletter aktiv')}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.recap.commsHint', 'Push wirkt vor allem live. Teile den Link manuell mit deinem Team oder auf Social Media.')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-800 dark:text-rose-100">
|
||||
{t('events.recap.manual', 'Manuell')}
|
||||
</Badge>
|
||||
</div>
|
||||
{event.public_url ? (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-3 text-sm text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-slate-200">
|
||||
<div className="flex-1 truncate" title={event.public_url}>{event.public_url}</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigator.clipboard.writeText(event.public_url!)}>
|
||||
{t('events.recap.copyLink', 'Link kopieren')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType<typeof useNavigate> }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
@@ -664,6 +833,7 @@ function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tas
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: EventToolkitTask }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex items-start justify-between rounded-lg border border-pink-100 bg-white/80 px-3 py-2 text-xs text-slate-600">
|
||||
<div className="space-y-1">
|
||||
@@ -671,7 +841,7 @@ function TaskRow({ task }: { task: EventToolkitTask }) {
|
||||
{task.description ? <p>{task.description}</p> : null}
|
||||
</div>
|
||||
<Badge variant={task.is_completed ? 'default' : 'outline'} className={task.is_completed ? 'bg-emerald-500/20 text-emerald-600' : 'border-pink-200 text-pink-600'}>
|
||||
{task.is_completed ? 'Erledigt' : 'Offen'}
|
||||
{task.is_completed ? t('events.tasks.status.completed', 'Done') : t('events.tasks.status.open', 'Open')}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
TenantEvent,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { isApiError } from '../lib/apiError';
|
||||
import { getApiErrorMessage, isApiError } from '../lib/apiError';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
|
||||
@@ -68,7 +68,8 @@ export default function EventFormPage() {
|
||||
const isEdit = Boolean(slugParam);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' });
|
||||
const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
|
||||
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
|
||||
const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' });
|
||||
|
||||
const [form, setForm] = React.useState<EventFormState>({
|
||||
@@ -195,6 +196,21 @@ export default function EventFormPage() {
|
||||
slugSuffixRef.current = null;
|
||||
}, [isEdit, loadedEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEdit || !loadedEvent || !eventTypes || eventTypes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedEvent.event_type_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
eventTypeId: prev.eventTypeId ?? eventTypes[0]!.id,
|
||||
}));
|
||||
}, [eventTypes, isEdit, loadedEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEdit || !eventLoadError) {
|
||||
return;
|
||||
@@ -266,7 +282,7 @@ export default function EventFormPage() {
|
||||
const trimmedName = form.name.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
setError('Bitte gib einen Eventnamen ein.');
|
||||
setError(tForm('errors.nameRequired', 'Bitte gib einen Eventnamen ein.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,7 +292,7 @@ export default function EventFormPage() {
|
||||
}
|
||||
|
||||
if (!form.eventTypeId) {
|
||||
setError('Bitte wähle einen Event-Typ aus.');
|
||||
setError(tForm('errors.typeRequired', 'Bitte wähle einen Event-Typ aus.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -297,9 +313,7 @@ export default function EventFormPage() {
|
||||
event_type_id: form.eventTypeId,
|
||||
event_date: form.date || undefined,
|
||||
status,
|
||||
...(shouldIncludePackage && packageIdForSubmit
|
||||
? { package_id: Number(packageIdForSubmit) }
|
||||
: {}),
|
||||
...(packageIdForSubmit ? { package_id: Number(packageIdForSubmit) } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -324,25 +338,24 @@ export default function EventFormPage() {
|
||||
const limit = Number(err.meta?.limit ?? 0);
|
||||
const used = Number(err.meta?.used ?? 0);
|
||||
const remaining = Number(err.meta?.remaining ?? Math.max(0, limit - used));
|
||||
const detail = limit > 0
|
||||
? tCommon('eventLimitDetails', { used, limit, remaining })
|
||||
: '';
|
||||
setError(`${tCommon('eventLimit')}${detail ? `\n${detail}` : ''}`);
|
||||
const detail = limit > 0 ? tErrors('eventLimitDetails', { used, limit, remaining }) : '';
|
||||
setError(`${tErrors('eventLimit')}${detail ? `\n${detail}` : ''}`);
|
||||
setShowUpgradeHint(true);
|
||||
break;
|
||||
}
|
||||
case 'event_credits_exhausted': {
|
||||
setError(tCommon('creditsExhausted'));
|
||||
setError(tErrors('creditsExhausted'));
|
||||
setShowUpgradeHint(true);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
setError(err.message || tCommon('generic'));
|
||||
const metaErrors = Array.isArray(err.meta?.errors) ? err.meta.errors.filter(Boolean).join('\n') : null;
|
||||
setError(metaErrors || err.message || tErrors('generic'));
|
||||
setShowUpgradeHint(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(tCommon('generic'));
|
||||
setError(getApiErrorMessage(err, tErrors('generic')));
|
||||
setShowUpgradeHint(false);
|
||||
}
|
||||
}
|
||||
@@ -439,19 +452,19 @@ export default function EventFormPage() {
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
|
||||
<ArrowLeft className="h-4 w-4" /> {tForm('actions.backToList', 'Zurück zur Liste')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={isEdit ? 'Event bearbeiten' : 'Neues Event erstellen'}
|
||||
subtitle="Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen."
|
||||
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
|
||||
subtitle={tForm('subtitle', 'Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen.')}
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Hinweis</AlertTitle>
|
||||
<AlertTitle>{tForm('errors.notice', 'Hinweis')}</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
{error.split('\n').map((line, index) => (
|
||||
<span key={index}>{line}</span>
|
||||
@@ -459,7 +472,7 @@ export default function EventFormPage() {
|
||||
{showUpgradeHint && (
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_BILLING_PATH)}>
|
||||
{tCommon('goToBilling')}
|
||||
{tErrors('goToBilling', 'Zum Billing')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -490,10 +503,10 @@ export default function EventFormPage() {
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdetails
|
||||
<Sparkles className="h-5 w-5 text-pink-500" /> {tForm('sections.details.title', 'Eventdetails')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.
|
||||
{tForm('sections.details.description', 'Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -503,20 +516,20 @@ export default function EventFormPage() {
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="event-name">Eventname</Label>
|
||||
<Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label>
|
||||
<Input
|
||||
id="event-name"
|
||||
placeholder="z. B. Sommerfest 2025"
|
||||
placeholder={tForm('fields.name.placeholder', 'z. B. Sommerfest 2025')}
|
||||
value={form.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Die Kennung und Event-URL werden automatisch aus dem Namen generiert.
|
||||
{tForm('fields.name.help', 'Die Kennung und Event-URL werden automatisch aus dem Namen generiert.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="event-date">Datum</Label>
|
||||
<Label htmlFor="event-date">{tForm('fields.date.label', 'Datum')}</Label>
|
||||
<Input
|
||||
id="event-date"
|
||||
type="date"
|
||||
@@ -525,7 +538,7 @@ export default function EventFormPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="event-type">Event-Typ</Label>
|
||||
<Label htmlFor="event-type">{tForm('fields.type.label', 'Event-Typ')}</Label>
|
||||
<Select
|
||||
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
|
||||
@@ -533,7 +546,11 @@ export default function EventFormPage() {
|
||||
>
|
||||
<SelectTrigger id="event-type">
|
||||
<SelectValue
|
||||
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswählen'}
|
||||
placeholder={
|
||||
eventTypesLoading
|
||||
? tForm('fields.type.loading', 'Event-Typ wird geladen…')
|
||||
: tForm('fields.type.placeholder', 'Event-Typ auswählen')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -546,7 +563,7 @@ export default function EventFormPage() {
|
||||
</Select>
|
||||
{!eventTypesLoading && (!sortedEventTypes || sortedEventTypes.length === 0) ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.
|
||||
{tForm('fields.type.empty', 'Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -560,10 +577,10 @@ export default function EventFormPage() {
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="event-published" className="text-sm font-medium text-slate-800">
|
||||
Event sofort veroeffentlichen
|
||||
{tForm('fields.publish.label', 'Event sofort veröffentlichen')}
|
||||
</Label>
|
||||
<p className="text-xs text-slate-600">
|
||||
Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.
|
||||
{tForm('fields.publish.help', 'Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,16 +593,16 @@ export default function EventFormPage() {
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Speichert
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {tForm('actions.saving', 'Speichert')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" /> Speichern
|
||||
<Save className="h-4 w-4" /> {tForm('actions.save', 'Speichern')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
|
||||
Abbrechen
|
||||
{tForm('actions.cancel', 'Abbrechen')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="sm:col-span-2 mt-6">
|
||||
|
||||
931
resources/js/admin/pages/EventRecapPage.tsx
Normal file
931
resources/js/admin/pages/EventRecapPage.tsx
Normal file
@@ -0,0 +1,931 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Camera, Clock3, Loader2, MessageSquare, Printer, ShoppingCart, Sparkles } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
createEventAddonCheckout,
|
||||
EventQrInvite,
|
||||
EventStats,
|
||||
getEvent,
|
||||
getEventStats,
|
||||
getEventQrInvites,
|
||||
getAddonCatalog,
|
||||
type EventAddonCatalogItem,
|
||||
TenantEvent,
|
||||
toggleEvent,
|
||||
submitTenantFeedback,
|
||||
} from '../api';
|
||||
import { updateEvent } from '../api';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
import { formatEventDate } from '../lib/events';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import {
|
||||
ADMIN_EVENT_EDIT_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
} from '../constants';
|
||||
|
||||
export default function EventRecapPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const slug = slugParam ?? null;
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [settingsBusy, setSettingsBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [joinTokens, setJoinTokens] = React.useState<EventQrInvite[]>([]);
|
||||
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [addonBusyKey, setAddonBusyKey] = React.useState<string | null>(null);
|
||||
|
||||
const loadEventData = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setLoading(false);
|
||||
setError(t('events.errors.missingSlug', 'Kein Event ausgewählt.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [eventData, statsData, invites, addons] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventStats(slug),
|
||||
getEventQrInvites(slug),
|
||||
getAddonCatalog(),
|
||||
]);
|
||||
|
||||
setEvent(eventData);
|
||||
setStats(statsData);
|
||||
setJoinTokens(invites);
|
||||
setAddonsCatalog(addons);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadEventData();
|
||||
}, [loadEventData]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!searchParams.get('addon_success')) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||||
void loadEventData();
|
||||
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('addon_success');
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [loadEventData, searchParams, setSearchParams, t]);
|
||||
|
||||
const handleToggleEvent = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await toggleEvent(slug);
|
||||
setEvent(updated);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
const handleAddonCheckout = React.useCallback(async (addonKey: string) => {
|
||||
if (!slug) return;
|
||||
|
||||
setAddonBusyKey(addonKey);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const currentUrl = window.location.origin + window.location.pathname;
|
||||
const successUrl = `${currentUrl}?addon_success=1`;
|
||||
const checkout = await createEventAddonCheckout(slug, {
|
||||
addon_key: addonKey,
|
||||
quantity: 1,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
});
|
||||
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
} else {
|
||||
toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
|
||||
}
|
||||
} finally {
|
||||
setAddonBusyKey(null);
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
const handleArchive = React.useCallback(async () => {
|
||||
if (!slug || !event) return;
|
||||
setArchiveBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
|
||||
setEvent(updated);
|
||||
setArchiveOpen(false);
|
||||
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
|
||||
}
|
||||
} finally {
|
||||
setArchiveBusy(false);
|
||||
}
|
||||
}, [event, slug, t]);
|
||||
|
||||
const handleToggleSetting = React.useCallback(async (key: 'guest_downloads_enabled' | 'guest_sharing_enabled', value: boolean) => {
|
||||
if (!slug || !event) return;
|
||||
setSettingsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateEvent(slug, {
|
||||
settings: {
|
||||
...(event.settings ?? {}),
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
setEvent(updated);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.')));
|
||||
}
|
||||
} finally {
|
||||
setSettingsBusy(false);
|
||||
}
|
||||
}, [event, slug, t]);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('events.errors.notFoundTitle', 'Event nicht gefunden')}
|
||||
subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')}
|
||||
>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
||||
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
|
||||
</Alert>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||
const activeInvite = joinTokens.find((token) => token.is_active);
|
||||
const guestLinkRaw = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
|
||||
const guestLink = buildAbsoluteGuestLink(guestLinkRaw);
|
||||
const guestQrCodeDataUrl = activeInvite?.qr_code_data_url ?? null;
|
||||
const eventTabs = event
|
||||
? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), {
|
||||
photos: stats?.uploads_total ?? event.photo_count ?? undefined,
|
||||
tasks: stats?.uploads_total ?? event.tasks_count ?? undefined,
|
||||
invites: event.active_invites_count ?? event.total_invites_count ?? undefined,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={eventName}
|
||||
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.')}
|
||||
tabs={eventTabs}
|
||||
currentTabKey="recap"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<WorkspaceSkeleton />
|
||||
) : event && stats ? (
|
||||
<RecapContent
|
||||
event={event}
|
||||
stats={stats}
|
||||
busy={busy}
|
||||
onToggleEvent={handleToggleEvent}
|
||||
guestLink={guestLink}
|
||||
guestQrCodeDataUrl={guestQrCodeDataUrl}
|
||||
addonsCatalog={addonsCatalog}
|
||||
addonBusyKey={addonBusyKey}
|
||||
onCheckoutAddon={handleAddonCheckout}
|
||||
onArchive={handleArchive}
|
||||
onCopyLink={guestLink ? () => {
|
||||
navigator.clipboard.writeText(guestLink);
|
||||
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
|
||||
} : undefined}
|
||||
onOpenPhotos={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
|
||||
onEditEvent={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
|
||||
onBack={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
settingsBusy={settingsBusy}
|
||||
onToggleDownloads={(value) => handleToggleSetting('guest_downloads_enabled', value)}
|
||||
onToggleSharing={(value) => handleToggleSetting('guest_sharing_enabled', value)}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
||||
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
type RecapContentProps = {
|
||||
event: TenantEvent;
|
||||
stats: EventStats;
|
||||
busy: boolean;
|
||||
onToggleEvent: () => void;
|
||||
guestLink: string | null;
|
||||
guestQrCodeDataUrl: string | null;
|
||||
addonsCatalog: EventAddonCatalogItem[];
|
||||
addonBusyKey: string | null;
|
||||
onCheckoutAddon: (addonKey: string) => void;
|
||||
onArchive: () => void;
|
||||
onCopyLink?: () => void;
|
||||
onOpenPhotos: () => void;
|
||||
onEditEvent: () => void;
|
||||
onBack: () => void;
|
||||
settingsBusy: boolean;
|
||||
onToggleDownloads: (value: boolean) => void;
|
||||
onToggleSharing: (value: boolean) => void;
|
||||
};
|
||||
|
||||
function RecapContent({
|
||||
event,
|
||||
stats,
|
||||
busy,
|
||||
onToggleEvent,
|
||||
guestLink,
|
||||
guestQrCodeDataUrl,
|
||||
addonsCatalog,
|
||||
addonBusyKey,
|
||||
onCheckoutAddon,
|
||||
onArchive,
|
||||
onCopyLink,
|
||||
onOpenPhotos,
|
||||
onEditEvent,
|
||||
onBack,
|
||||
settingsBusy,
|
||||
onToggleDownloads,
|
||||
onToggleSharing,
|
||||
}: RecapContentProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null;
|
||||
const galleryStatusLabel = event.is_active
|
||||
? t('events.recap.galleryOpen', 'Galerie geöffnet')
|
||||
: t('events.recap.galleryClosed', 'Galerie geschlossen');
|
||||
|
||||
const counts = {
|
||||
photos: stats.uploads_total ?? stats.total ?? 0,
|
||||
pending: stats.pending_photos ?? 0,
|
||||
likes: stats.likes_total ?? stats.likes ?? 0,
|
||||
};
|
||||
|
||||
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
|
||||
const [feedbackMessage, setFeedbackMessage] = React.useState('');
|
||||
const [feedbackBusy, setFeedbackBusy] = React.useState(false);
|
||||
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
|
||||
const [feedbackError, setFeedbackError] = React.useState<string | undefined>(undefined);
|
||||
const [archiveOpen, setArchiveOpen] = React.useState(false);
|
||||
const [archiveBusy, setArchiveBusy] = React.useState(false);
|
||||
const [archiveConfirmed, setArchiveConfirmed] = React.useState(false);
|
||||
const [feedbackOpen, setFeedbackOpen] = React.useState(false);
|
||||
const [feedbackBestArea, setFeedbackBestArea] = React.useState<string | null>(null);
|
||||
const [feedbackNeedsSupport, setFeedbackNeedsSupport] = React.useState(false);
|
||||
|
||||
const galleryAddons = React.useMemo(
|
||||
() => addonsCatalog.filter((addon) => addon.key.includes('gallery') || addon.key.includes('boost')),
|
||||
[addonsCatalog],
|
||||
);
|
||||
const addonsToShow = galleryAddons.length ? galleryAddons : addonsCatalog;
|
||||
const defaultAddon = addonsToShow[0] ?? null;
|
||||
|
||||
const describeAddon = React.useCallback((addon: EventAddonCatalogItem): string | null => {
|
||||
const increments = addon.increments ?? {};
|
||||
const photos = (increments as Record<string, number | undefined>).photos ?? (increments as Record<string, number | undefined>).extra_photos;
|
||||
const guests = (increments as Record<string, number | undefined>).guests ?? (increments as Record<string, number | undefined>).extra_guests;
|
||||
const galleryDays = (increments as Record<string, number | undefined>).gallery_days
|
||||
?? (increments as Record<string, number | undefined>).extra_gallery_days;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (typeof photos === 'number' && photos > 0) {
|
||||
parts.push(t('events.sections.addons.summary.photos', `+${photos} Fotos`, { count: photos.toLocaleString() }));
|
||||
}
|
||||
|
||||
if (typeof guests === 'number' && guests > 0) {
|
||||
parts.push(t('events.sections.addons.summary.guests', `+${guests} Gäste`, { count: guests.toLocaleString() }));
|
||||
}
|
||||
|
||||
if (typeof galleryDays === 'number' && galleryDays > 0) {
|
||||
parts.push(t('events.sections.addons.summary.gallery', `+${galleryDays} Tage`, { count: galleryDays }));
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(' · ') : null;
|
||||
}, [t]);
|
||||
|
||||
const copy = {
|
||||
positive: t('events.feedback.positive', 'War super'),
|
||||
neutral: t('events.feedback.neutral', 'In Ordnung'),
|
||||
negative: t('events.feedback.negative', 'Brauch(t)e Unterstützung'),
|
||||
};
|
||||
|
||||
const bestAreaOptions = [
|
||||
{ key: 'uploads', label: t('events.feedback.best.uploads', 'Uploads & Geschwindigkeit') },
|
||||
{ key: 'invites', label: t('events.feedback.best.invites', 'QR-Einladungen & Layouts') },
|
||||
{ key: 'moderation', label: t('events.feedback.best.moderation', 'Moderation & Export') },
|
||||
{ key: 'experience', label: t('events.feedback.best.experience', 'Allgemeine App-Erfahrung') },
|
||||
];
|
||||
|
||||
const handleQrDownload = React.useCallback(() => {
|
||||
if (!guestQrCodeDataUrl) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = guestQrCodeDataUrl;
|
||||
link.download = 'guest-gallery-qr.png';
|
||||
link.click();
|
||||
}, [guestQrCodeDataUrl]);
|
||||
|
||||
const handleQrShare = React.useCallback(async () => {
|
||||
if (!guestLink) return;
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: resolveName(event.name),
|
||||
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
|
||||
url: guestLink,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Ignore share cancellation and fall back to copy.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(guestLink);
|
||||
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
|
||||
} catch {
|
||||
toast.error(t('events.recap.copyError', 'Link konnte nicht geteilt werden.'));
|
||||
}
|
||||
}, [event.name, guestLink, t]);
|
||||
|
||||
const handleFeedbackSubmit = React.useCallback(async () => {
|
||||
if (feedbackBusy) return;
|
||||
|
||||
setFeedbackBusy(true);
|
||||
setFeedbackError(undefined);
|
||||
|
||||
try {
|
||||
await submitTenantFeedback({
|
||||
category: 'event_workspace_after_event',
|
||||
event_slug: event.slug,
|
||||
sentiment: sentiment ?? undefined,
|
||||
message: feedbackMessage.trim() ? feedbackMessage.trim() : undefined,
|
||||
metadata: {
|
||||
best_area: feedbackBestArea,
|
||||
needs_support: feedbackNeedsSupport,
|
||||
event_name: resolveName(event.name),
|
||||
guest_link: guestLink,
|
||||
},
|
||||
});
|
||||
|
||||
setFeedbackSubmitted(true);
|
||||
setFeedbackOpen(false);
|
||||
setFeedbackMessage('');
|
||||
setFeedbackNeedsSupport(false);
|
||||
toast.success(t('events.feedback.submitted', 'Danke!'));
|
||||
} catch (err) {
|
||||
setFeedbackError(isAuthError(err)
|
||||
? t('events.feedback.authError', 'Deine Session ist abgelaufen. Bitte melde dich erneut an.')
|
||||
: t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.'));
|
||||
} finally {
|
||||
setFeedbackBusy(false);
|
||||
}
|
||||
}, [event.slug, event.name, feedbackBestArea, feedbackBusy, feedbackMessage, feedbackNeedsSupport, feedbackSubmitted, guestLink, sentiment, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
|
||||
{t('events.recap.badge', 'Nachbereitung')}
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{event.status === 'published' ? t('events.status.published', 'Veröffentlicht') : t('events.status.draft', 'Entwurf')}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{event.event_date ? formatEventDate(event.event_date, undefined) : t('events.workspace.noDate', 'Kein Datum')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onBack} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onEditEvent} className="rounded-full border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
|
||||
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleEvent}
|
||||
disabled={busy}
|
||||
className="rounded-full border-slate-200"
|
||||
>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-emerald-500">
|
||||
{t('events.recap.galleryTitle', 'Galerie-Status')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{galleryStatusLabel}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={event.is_active ? 'default' : 'outline'} className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-100">
|
||||
{event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant={event.is_active ? 'secondary' : 'default'} disabled={busy} onClick={onToggleEvent}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
{t('events.recap.moderate', 'Uploads ansehen')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 rounded-2xl border border-dashed border-emerald-200 bg-emerald-50/60 p-3 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-emerald-600 dark:text-emerald-200">
|
||||
{t('events.recap.shareLink', 'Gäste-Link')}
|
||||
</p>
|
||||
{guestLink ? (
|
||||
<span className="block truncate text-emerald-900" title={guestLink}>{guestLink}</span>
|
||||
) : (
|
||||
<p className="text-xs text-emerald-800/80 dark:text-emerald-100">
|
||||
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{guestLink && onCopyLink ? (
|
||||
<Button size="sm" variant="secondary" className="rounded-full bg-emerald-600 text-white hover:bg-emerald-700" onClick={onCopyLink}>
|
||||
{t('events.recap.copyLink', 'Link kopieren')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowDownloads', 'Downloads erlauben')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowDownloadsHint', 'Gäste dürfen Fotos speichern')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean((event.settings as any)?.guest_downloads_enabled ?? true)}
|
||||
onCheckedChange={(checked) => onToggleDownloads(Boolean(checked))}
|
||||
disabled={settingsBusy}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowSharing', 'Teilen erlauben')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowSharingHint', 'Gäste dürfen Links teilen')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean((event.settings as any)?.guest_sharing_enabled ?? true)}
|
||||
onCheckedChange={(checked) => onToggleSharing(Boolean(checked))}
|
||||
disabled={settingsBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{guestQrCodeDataUrl ? (
|
||||
<div className="mt-2 grid gap-3 rounded-2xl border border-emerald-100/80 bg-white/90 p-3 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-white/10 dark:text-emerald-50 sm:grid-cols-[auto,1fr]">
|
||||
<div className="flex items-center justify-center rounded-xl border border-emerald-100/70 bg-white/70 p-2 dark:border-emerald-900/50 dark:bg-emerald-900/30">
|
||||
<img src={guestQrCodeDataUrl} alt={t('events.recap.qrAlt', 'QR-Code zur Gäste-Galerie')} className="h-28 w-28 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.qrTitle', 'QR-Code teilen')}</p>
|
||||
{guestLink ? (
|
||||
<p className="truncate text-sm text-emerald-800 dark:text-emerald-100" title={guestLink}>{guestLink}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={handleQrDownload}>
|
||||
{t('events.recap.qrDownload', 'QR-Code herunterladen')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleQrShare}>
|
||||
{t('events.recap.qrShare', 'Link/QR teilen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-500">
|
||||
{t('events.recap.exportTitle', 'Export & Backup')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('events.recap.exportCopy', 'Alle Assets sichern')}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-indigo-200 text-indigo-700 dark:border-indigo-800 dark:text-indigo-200">
|
||||
{t('events.recap.backup', 'Backup')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={onOpenPhotos}>
|
||||
<Printer className="mr-2 h-4 w-4" />
|
||||
{t('events.recap.downloadAll', 'Alles herunterladen')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
|
||||
<Printer className="mr-2 h-4 w-4" />
|
||||
{t('events.recap.downloadHighlights', 'Highlights herunterladen')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('events.recap.highlightsHint', '“Highlights” = als Highlight markierte Fotos in der Galerie.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">
|
||||
{t('events.recap.retentionTitle', 'Verlängerung / Archivierung')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{galleryExpiresAt
|
||||
? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
|
||||
: t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-100">
|
||||
{t('events.recap.expiry', 'Ablauf')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={!defaultAddon || addonBusyKey === defaultAddon?.key}
|
||||
onClick={() => defaultAddon && onCheckoutAddon(defaultAddon.key)}
|
||||
>
|
||||
{addonBusyKey === defaultAddon?.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Clock3 className="mr-2 h-4 w-4" />}
|
||||
{defaultAddon?.label ?? t('events.actions.extendGallery', 'Galerie verlängern')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setArchiveOpen(true)}>
|
||||
{t('events.recap.archive', 'Archivieren/Löschen')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{addonsToShow.length ? (
|
||||
<div className="mt-4 space-y-3 rounded-2xl border border-amber-100/80 bg-white/80 p-3 text-sm text-slate-700 shadow-sm dark:border-amber-900/40 dark:bg-white/10 dark:text-slate-200">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-600 dark:text-amber-200">
|
||||
{t('events.recap.extendOptions', 'Alle Add-ons für dieses Event')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{addonsToShow.map((addon) => (
|
||||
<div
|
||||
key={addon.key}
|
||||
className="flex flex-col gap-2 rounded-xl border border-amber-100/60 bg-white/80 p-3 shadow-sm dark:border-amber-900/40 dark:bg-amber-900/20 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="truncate font-semibold text-slate-900 dark:text-white" title={addon.label}>
|
||||
{addon.label}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||||
{describeAddon(addon) ?? t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!addon.price_id || addonBusyKey === addon.key}
|
||||
onClick={() => onCheckoutAddon(addon.key)}
|
||||
>
|
||||
{addonBusyKey === addon.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ShoppingCart className="mr-2 h-4 w-4" />}
|
||||
{addon.price_id ? t('addons.buyNow', 'Jetzt freischalten') : t('events.recap.priceMissing', 'Preis nicht verknüpft')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-xs text-slate-500 dark:text-slate-300">
|
||||
{t('events.recap.noAddons', 'Aktuell keine Add-ons verfügbar.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-rose-500">
|
||||
{t('events.feedback.badge', 'Feedback')}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('events.feedback.afterEventTitle', 'Event beendet – kurzes Feedback?')}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.feedback.afterEventCopy', 'Hat alles geklappt? Deine Antwort hilft uns für kommende Events.')}<br />
|
||||
<span className="text-[11px] text-slate-500">{t('events.feedback.privacyHint', 'Nur Admin-Feedback, keine Gastdaten')}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-800 dark:text-rose-100">
|
||||
{t('events.feedback.badgeShort', 'Feedback')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
<Badge variant="secondary" className="rounded-full bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-50">
|
||||
{resolveName(event.name)}
|
||||
</Badge>
|
||||
{event.event_date ? (
|
||||
<Badge variant="outline" className="rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
|
||||
{formatEventDate(event.event_date, undefined)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{feedbackSubmitted ? (
|
||||
<div className="mt-4 rounded-2xl border border-emerald-200/60 bg-emerald-50/80 p-4 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
|
||||
<p className="font-semibold">{t('events.feedback.submitted', 'Danke!')}</p>
|
||||
<p className="text-sm">{t('events.feedback.afterEventThanks', 'Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.')}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => { setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
|
||||
{t('events.feedback.sendAnother', 'Weiteres Feedback senden')}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => { setFeedbackNeedsSupport(true); setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
|
||||
{t('events.feedback.supportFollowup', 'Support anfragen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<Button size="sm" onClick={() => setFeedbackOpen(true)} disabled={feedbackBusy}>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{t('events.feedback.cta', 'Feedback geben')}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
<span>{t('events.feedback.quickSentiment', 'Stimmung auswählbar (positiv/neutral/Support).')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedbackError ? (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
|
||||
<AlertDescription>{feedbackError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={feedbackOpen} onOpenChange={(open) => { setFeedbackOpen(open); setFeedbackError(undefined); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('events.feedback.dialogTitle', 'Kurzes After-Event Feedback')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('events.feedback.dialogCopy', 'Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.sentiment', 'Stimmung')}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sentiment === key ? 'default' : 'outline'}
|
||||
className={sentiment === key ? 'bg-slate-900 text-white' : 'border-slate-300 text-slate-700'}
|
||||
onClick={() => setSentiment(key)}
|
||||
>
|
||||
{copy[key]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.bestQuestion', 'Was lief am besten?')}</p>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{bestAreaOptions.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
type="button"
|
||||
variant={feedbackBestArea === option.key ? 'secondary' : 'outline'}
|
||||
className={feedbackBestArea === option.key ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 text-slate-700'}
|
||||
onClick={() => setFeedbackBestArea(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.improve', 'Was sollen wir verbessern?')}</p>
|
||||
<textarea
|
||||
value={feedbackMessage}
|
||||
onChange={(event) => setFeedbackMessage(event.target.value)}
|
||||
placeholder={t('events.feedback.placeholder', 'Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.')}
|
||||
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white/90 p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300 dark:border-white/10 dark:bg-white/5 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-xs text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<Checkbox
|
||||
id="needs-support"
|
||||
checked={feedbackNeedsSupport}
|
||||
onCheckedChange={(checked) => setFeedbackNeedsSupport(Boolean(checked))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<Label htmlFor="needs-support" className="cursor-pointer text-sm leading-5">
|
||||
{t('events.feedback.supportHelp', 'Ich hätte gern ein kurzes Follow-up (Support).')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFeedbackOpen(false)} disabled={feedbackBusy}>
|
||||
{t('common.cancel', 'Abbrechen')}
|
||||
</Button>
|
||||
<Button onClick={() => { void handleFeedbackSubmit(); }} disabled={feedbackBusy}>
|
||||
{feedbackBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />}
|
||||
{t('events.feedback.submit', 'Feedback senden')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={archiveOpen} onOpenChange={(open) => {
|
||||
setArchiveOpen(open);
|
||||
if (!open) {
|
||||
setArchiveConfirmed(false);
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('events.recap.archiveTitle', 'Event archivieren')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('events.recap.archiveDesc', 'Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte solltest du vorher abschließen.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-amber-200/60 bg-amber-50/70 p-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-900/30 dark:text-amber-50">
|
||||
<p className="font-semibold">{t('events.recap.archiveImpact', 'Was passiert?')}</p>
|
||||
<ul className="list-disc space-y-1 pl-4 text-amber-900 dark:text-amber-50">
|
||||
<li>{t('events.recap.archiveImpactClose', 'Gäste-Zugriff wird beendet, Uploads/Downloads werden deaktiviert.')}</li>
|
||||
<li>{t('events.recap.archiveImpactLinks', 'Öffentliche Links und QR-Codes werden ungültig, bestehende Sessions laufen aus.')}</li>
|
||||
<li>{t('events.recap.archiveImpactData', 'Daten bleiben intern für Compliance & Support sichtbar, können aber auf Anfrage gelöscht werden (DSGVO).')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-sm text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<Checkbox
|
||||
id="archive-confirm"
|
||||
checked={archiveConfirmed}
|
||||
onCheckedChange={(checked) => setArchiveConfirmed(Boolean(checked))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<Label htmlFor="archive-confirm" className="cursor-pointer text-sm leading-5">
|
||||
{t('events.recap.archiveConfirm', 'Ich habe Exporte abgeschlossen und möchte die Galerie jetzt archivieren.')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiveBusy}>
|
||||
{t('common.cancel', 'Abbrechen')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onArchive} disabled={!archiveConfirmed || archiveBusy}>
|
||||
{archiveBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t('events.recap.archiveConfirmCta', 'Archivierung starten')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string' && name.trim().length > 0) {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
||||
}
|
||||
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function buildAbsoluteGuestLink(link: string | null): string | null {
|
||||
if (!link) return null;
|
||||
|
||||
try {
|
||||
const base = typeof window !== 'undefined' ? window.location.origin : undefined;
|
||||
return base ? new URL(link, base).toString() : new URL(link).toString();
|
||||
} catch {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
|
||||
function WorkspaceSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<SkeletonCard key={`recap-metric-skeleton-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
|
||||
}
|
||||
@@ -36,7 +36,8 @@ import { filterEmotionsByEventType } from '../lib/emotions';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
|
||||
export default function EventTasksPage() {
|
||||
const { t } = useTranslation(['management', 'dashboard']);
|
||||
const { t } = useTranslation('management', { keyPrefix: 'eventTasks' });
|
||||
const { t: tDashboard } = useTranslation('dashboard');
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
||||
@@ -67,7 +68,7 @@ export default function EventTasksPage() {
|
||||
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('management.tasks.errors.assign', 'Tasks konnten nicht geladen werden.'));
|
||||
setError(t('errors.assign', 'Tasks konnten nicht geladen werden.'));
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
@@ -88,7 +89,7 @@ export default function EventTasksPage() {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setError(t('management.tasks.errors.missingSlug', 'Kein Event-Slug angegeben.'));
|
||||
setError(t('errors.missingSlug', 'Kein Event-Slug angegeben.'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -120,7 +121,7 @@ export default function EventTasksPage() {
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('management.tasks.errors.load', 'Event-Tasks konnten nicht geladen werden.'));
|
||||
setError(t('errors.load', 'Event-Tasks konnten nicht geladen werden.'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -146,7 +147,7 @@ export default function EventTasksPage() {
|
||||
setSelected([]);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('management.tasks.errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
|
||||
setError(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -178,14 +179,13 @@ export default function EventTasksPage() {
|
||||
}, [event, assignedTasks.length, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!event?.event_type?.slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setCollectionsLoading(true);
|
||||
setCollectionsError(null);
|
||||
getTaskCollections({ per_page: 6, event_type: event.event_type.slug })
|
||||
const eventTypeSlug = event?.event_type?.slug ?? null;
|
||||
const query = eventTypeSlug ? { per_page: 6, event_type: eventTypeSlug } : { per_page: 6 };
|
||||
|
||||
getTaskCollections(query)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setCollections(result.data);
|
||||
@@ -193,7 +193,7 @@ export default function EventTasksPage() {
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
if (!isAuthError(err)) {
|
||||
setCollectionsError(t('management.tasks.collections.error', 'Kollektionen konnten nicht geladen werden.'));
|
||||
setCollectionsError(t('collections.error', 'Kollektionen konnten nicht geladen werden.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -244,7 +244,7 @@ export default function EventTasksPage() {
|
||||
try {
|
||||
await importTaskCollection(collection.id, slug);
|
||||
toast.success(
|
||||
t('management.tasks.collections.imported', {
|
||||
t('collections.imported', {
|
||||
defaultValue: 'Mission Pack "{{name}}" importiert.',
|
||||
name: collection.name,
|
||||
}),
|
||||
@@ -252,16 +252,16 @@ export default function EventTasksPage() {
|
||||
await hydrateTasks(event);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('management.tasks.collections.importFailed', 'Mission Pack konnte nicht importiert werden.'));
|
||||
toast.error(t('collections.importFailed', 'Mission Pack konnte nicht importiert werden.'));
|
||||
}
|
||||
} finally {
|
||||
setImportingCollectionId(null);
|
||||
}
|
||||
}, [event, hydrateTasks, slug, t]);
|
||||
|
||||
const isPhotoOnlyMode = React.useMemo(() => {
|
||||
const tasksEnabled = React.useMemo(() => {
|
||||
const mode = event?.engagement_mode ?? (event?.settings as any)?.engagement_mode;
|
||||
return mode === 'photo_only';
|
||||
return mode !== 'photo_only';
|
||||
}, [event?.engagement_mode, event?.settings]);
|
||||
|
||||
async function handleModeChange(checked: boolean) {
|
||||
@@ -271,7 +271,7 @@ export default function EventTasksPage() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextMode = checked ? 'photo_only' : 'tasks';
|
||||
const nextMode = checked ? 'tasks' : 'photo_only';
|
||||
const updated = await updateEvent(slug, {
|
||||
settings: {
|
||||
...(event.settings ?? {}),
|
||||
@@ -292,8 +292,8 @@ export default function EventTasksPage() {
|
||||
if (!isAuthError(err)) {
|
||||
setError(
|
||||
checked
|
||||
? t('management.tasks.errors.photoOnlyEnable', 'Foto-Modus konnte nicht aktiviert werden.')
|
||||
: t('management.tasks.errors.photoOnlyDisable', 'Foto-Modus konnte nicht deaktiviert werden.'),
|
||||
? t('errors.photoOnlyEnable', 'Foto-Modus konnte nicht aktiviert werden.')
|
||||
: t('errors.photoOnlyDisable', 'Foto-Modus konnte nicht deaktiviert werden.'),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -304,21 +304,21 @@ 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" />
|
||||
{t('management.tasks.actions.back', 'Zurück zur Übersicht')}
|
||||
{t('actions.back', 'Zurück zur Übersicht')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('management.tasks.title', 'Aufgaben & Missionen')}
|
||||
subtitle={t('management.tasks.subtitle', 'Stelle Mission Cards und Aufgaben für dieses Event zusammen.')}
|
||||
title={t('title', 'Aufgaben & Missionen')}
|
||||
subtitle={t('subtitle', 'Stelle Mission Cards und Aufgaben für dieses Event zusammen.')}
|
||||
actions={actions}
|
||||
tabs={eventTabs}
|
||||
currentTabKey="tasks"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -327,22 +327,22 @@ export default function EventTasksPage() {
|
||||
<TaskSkeleton />
|
||||
) : !event ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('management.tasks.alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
||||
<AlertDescription>{t('management.tasks.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
|
||||
<AlertTitle>{t('alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
||||
<AlertDescription>{t('alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as 'tasks' | 'packs')} className="space-y-6">
|
||||
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 sm:grid-cols-2">
|
||||
<TabsTrigger value="tasks">{t('management.tasks.tabs.tasks', 'Aufgaben')}</TabsTrigger>
|
||||
<TabsTrigger value="packs">{t('management.tasks.tabs.packs', 'Mission Packs')}</TabsTrigger>
|
||||
<TabsTrigger value="tasks">{t('tabs.tasks', 'Aufgaben')}</TabsTrigger>
|
||||
<TabsTrigger value="packs">{t('tabs.packs', 'Mission Packs')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tasks" className="space-y-6">
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.tasks.eventStatus', {
|
||||
{t('eventStatus', {
|
||||
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
|
||||
})}
|
||||
</CardDescription>
|
||||
@@ -350,52 +350,44 @@ export default function EventTasksPage() {
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{t('management.tasks.modes.title', 'Aufgaben & Foto-Modus')}
|
||||
{t('modes.title', 'Aufgaben & Foto-Modus')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{isPhotoOnlyMode
|
||||
? t(
|
||||
'management.tasks.modes.photoOnlyHint',
|
||||
'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.',
|
||||
)
|
||||
: t(
|
||||
'management.tasks.modes.tasksHint',
|
||||
'Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.',
|
||||
)}
|
||||
{tasksEnabled
|
||||
? t('modes.tasksHint', 'Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.')
|
||||
: t('modes.photoOnlyHint', 'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{isPhotoOnlyMode
|
||||
? t('management.tasks.modes.photoOnly', 'Foto-Modus')
|
||||
: t('management.tasks.modes.tasks', 'Aufgaben aktiv')}
|
||||
{tasksEnabled ? t('modes.tasks', 'Aufgaben aktiv') : t('modes.photoOnly', 'Foto-Modus')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isPhotoOnlyMode}
|
||||
checked={tasksEnabled}
|
||||
onCheckedChange={handleModeChange}
|
||||
disabled={modeSaving}
|
||||
aria-label={t('management.tasks.modes.switchLabel', 'Foto-Modus aktivieren')}
|
||||
aria-label={t('modes.switchLabel', 'Aufgaben aktivieren/deaktivieren')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{modeSaving ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('management.tasks.modes.updating', 'Einstellung wird gespeichert ...')}
|
||||
{t('modes.updating', 'Einstellung wird gespeichert ...')}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 text-xs sm:grid-cols-3">
|
||||
<SummaryPill
|
||||
label={t('management.tasks.summary.assigned', 'Zugeordnete Tasks')}
|
||||
label={t('summary.assigned', 'Zugeordnete Tasks')}
|
||||
value={assignedTasks.length}
|
||||
/>
|
||||
<SummaryPill
|
||||
label={t('management.tasks.summary.library', 'Bibliothek')}
|
||||
label={t('summary.library', 'Bibliothek')}
|
||||
value={availableTasks.length}
|
||||
/>
|
||||
<SummaryPill
|
||||
label={t('management.tasks.summary.mode', 'Aktiver Modus')}
|
||||
value={isPhotoOnlyMode ? t('management.tasks.summary.photoOnly', 'Nur Fotos') : t('management.tasks.summary.tasksMode', 'Mission Cards')}
|
||||
label={t('summary.mode', 'Aktiver Modus')}
|
||||
value={tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,14 +395,11 @@ export default function EventTasksPage() {
|
||||
<CardContent className="pb-0">
|
||||
<Alert variant="default" className="rounded-2xl border border-dashed border-emerald-200 bg-emerald-50/60 text-xs text-slate-700">
|
||||
<AlertTitle className="text-sm font-semibold text-slate-900">
|
||||
{t('management.tasks.library.hintTitle', 'Weitere Vorlagen in der Aufgaben-Bibliothek')}
|
||||
{t('library.hintTitle', 'Weitere Vorlagen in der Aufgaben-Bibliothek')}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
{t(
|
||||
'management.tasks.library.hintCopy',
|
||||
'Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.',
|
||||
)}
|
||||
{t('library.hintCopy', 'Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -419,7 +408,7 @@ export default function EventTasksPage() {
|
||||
className="mt-1 rounded-full border-emerald-300 text-emerald-700 hover:bg-emerald-100"
|
||||
onClick={() => navigate(buildEngagementTabPath('tasks'))}
|
||||
>
|
||||
{t('management.tasks.library.open', 'Aufgaben-Bibliothek öffnen')}
|
||||
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -429,14 +418,14 @@ export default function EventTasksPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Sparkles className="h-4 w-4 text-pink-500" />
|
||||
{t('management.tasks.sections.assigned.title', 'Zugeordnete Tasks')}
|
||||
{t('sections.assigned.title', 'Zugeordnete Tasks')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
|
||||
<Search className="h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
value={taskSearch}
|
||||
onChange={(event) => setTaskSearch(event.target.value)}
|
||||
placeholder={t('management.tasks.sections.assigned.search', 'Aufgaben suchen...')}
|
||||
placeholder={t('sections.assigned.search', 'Aufgaben suchen...')}
|
||||
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -446,8 +435,8 @@ export default function EventTasksPage() {
|
||||
<EmptyState
|
||||
message={
|
||||
taskSearch.trim()
|
||||
? t('management.tasks.sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
|
||||
: t('management.tasks.sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
|
||||
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
|
||||
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -462,11 +451,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" />
|
||||
{t('management.tasks.sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
|
||||
{t('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={t('management.tasks.sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
|
||||
<EmptyState message={t('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">
|
||||
@@ -477,7 +466,7 @@ export default function EventTasksPage() {
|
||||
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
|
||||
)
|
||||
}
|
||||
disabled={isPhotoOnlyMode}
|
||||
disabled={!tasksEnabled}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
@@ -489,9 +478,9 @@ export default function EventTasksPage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void handleAssign()}
|
||||
disabled={saving || selected.length === 0 || isPhotoOnlyMode}
|
||||
disabled={saving || selected.length === 0 || !tasksEnabled}
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.assign', 'Ausgewählte Tasks zuweisen')}
|
||||
</Button>
|
||||
</section>
|
||||
</CardContent>
|
||||
@@ -577,7 +566,7 @@ function MissionPackGrid({
|
||||
importingId: number | null;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { t } = useTranslation('management', { keyPrefix: 'eventTasks.collections' });
|
||||
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white/90">
|
||||
@@ -585,20 +574,20 @@ function MissionPackGrid({
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base text-slate-900">
|
||||
<Layers className="h-5 w-5 text-pink-500" />
|
||||
{t('management.tasks.collections.title', 'Mission Packs')}
|
||||
{t('title', 'Mission Packs')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.tasks.collections.subtitle', 'Importiere Aufgaben-Kollektionen, die zu deinem Event passen.')}
|
||||
{t('subtitle', 'Importiere Aufgaben-Kollektionen, die zu deinem Event passen.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onViewAll}>
|
||||
{t('management.tasks.collections.viewAll', 'Alle Kollektionen ansehen')}
|
||||
{t('viewAll', 'Alle Kollektionen ansehen')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('management.tasks.collections.errorTitle', 'Kollektionen nicht verfügbar')}</AlertTitle>
|
||||
<AlertTitle>{t('errorTitle', 'Kollektionen nicht verfügbar')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -610,7 +599,7 @@ function MissionPackGrid({
|
||||
))}
|
||||
</div>
|
||||
) : collections.length === 0 ? (
|
||||
<EmptyState message={t('management.tasks.collections.empty', 'Keine empfohlenen Kollektionen gefunden.')} />
|
||||
<EmptyState message={t('empty', 'Keine empfohlenen Kollektionen gefunden.')} />
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{collections.map((collection) => (
|
||||
@@ -621,15 +610,15 @@ function MissionPackGrid({
|
||||
<p className="text-xs text-slate-500">{collection.description}</p>
|
||||
) : null}
|
||||
<Badge variant="outline" className="w-fit border-slate-200 text-slate-600">
|
||||
{t('management.tasks.collections.tasksCount', {
|
||||
{t('tasksCount', {
|
||||
defaultValue: '{{count}} Aufgaben',
|
||||
count: collection.tasks_count,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between text-xs text-slate-500">
|
||||
<span>{collection.event_type?.name ?? t('management.tasks.collections.genericType', 'Allgemein')}</span>
|
||||
<span>{collection.is_global ? t('management.tasks.collections.global', 'Global') : t('management.tasks.collections.custom', 'Custom')}</span>
|
||||
<span>{collection.event_type?.name ?? t('genericType', 'Allgemein')}</span>
|
||||
<span>{collection.is_global ? t('global', 'Global') : t('custom', 'Custom')}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="mt-4 rounded-full bg-brand-rose text-white"
|
||||
@@ -639,7 +628,7 @@ function MissionPackGrid({
|
||||
{importingId === collection.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
t('management.tasks.collections.importCta', 'Mission Pack importieren')
|
||||
t('importCta', 'Mission Pack importieren')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -793,13 +782,13 @@ function SummaryPill({ label, value }: { label: string; value: string | number }
|
||||
function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string {
|
||||
switch (priority) {
|
||||
case 'low':
|
||||
return translate('management.tasks.priorities.low', 'Niedrig');
|
||||
return translate('management.eventTasks.priorities.low', 'Niedrig');
|
||||
case 'high':
|
||||
return translate('management.tasks.priorities.high', 'Hoch');
|
||||
return translate('management.eventTasks.priorities.high', 'Hoch');
|
||||
case 'urgent':
|
||||
return translate('management.tasks.priorities.urgent', 'Dringend');
|
||||
return translate('management.eventTasks.priorities.urgent', 'Dringend');
|
||||
default:
|
||||
return translate('management.tasks.priorities.medium', 'Mittel');
|
||||
return translate('management.eventTasks.priorities.medium', 'Mittel');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
|
||||
export default function LoginStartPage(): React.ReactElement {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -20,7 +22,7 @@ export default function LoginStartPage(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70">
|
||||
<p className="text-sm font-medium">Weiterleitung zum Login …</p>
|
||||
<p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export type TasksSectionProps = {
|
||||
|
||||
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('common');
|
||||
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
|
||||
const { t: tc } = useTranslation('common');
|
||||
|
||||
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
|
||||
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
|
||||
@@ -75,7 +76,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Tasks konnten nicht geladen werden.');
|
||||
setError(t('errors.load'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -87,7 +88,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page, search]);
|
||||
}, [page, search, t]);
|
||||
|
||||
const openCreate = React.useCallback(() => {
|
||||
setEditingTask(null);
|
||||
@@ -179,16 +180,14 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
}
|
||||
}
|
||||
|
||||
const title = embedded ? 'Aufgaben' : 'Task Bibliothek';
|
||||
const subtitle = embedded
|
||||
? 'Plane Aufgaben, Aktionen und Highlights für deine Gäste.'
|
||||
: 'Weise Aufgaben zu und tracke Fortschritt rund um deine Events.';
|
||||
const title = embedded ? t('titles.embedded') : t('titles.default');
|
||||
const subtitle = embedded ? t('subtitles.embedded') : t('subtitles.default');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertTitle>{t('errors.title')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -201,21 +200,21 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleNavigateToCollections}>
|
||||
{t('navigation.collections')}
|
||||
{tc('navigation.collections')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neu
|
||||
{t('actions.new')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Input
|
||||
placeholder="Nach Aufgaben suchen ..."
|
||||
placeholder={t('actions.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
@@ -225,7 +224,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
/>
|
||||
{meta && meta.total > 0 ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
Seite {meta.current_page} von {meta.last_page} · {meta.total} Einträge
|
||||
{t('pagination.page', {
|
||||
current: meta.current_page,
|
||||
total: meta.last_page,
|
||||
count: meta.total,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -251,11 +254,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
{meta && meta.last_page > 1 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
|
||||
<div className="text-slate-500">
|
||||
Insgesamt {meta.total} Aufgaben · Seite {meta.current_page} von {meta.last_page}
|
||||
{t('pagination.summary', { count: meta.total, current: meta.current_page, total: meta.last_page })}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((page) => Math.max(page - 1, 1))} disabled={meta.current_page <= 1}>
|
||||
Zurück
|
||||
{t('pagination.prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -263,7 +266,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
onClick={() => setPage((page) => Math.min(page + 1, meta.last_page ?? page + 1))}
|
||||
disabled={meta.current_page >= (meta.last_page ?? 1)}
|
||||
>
|
||||
Weiter
|
||||
{t('pagination.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,11 +277,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTask ? 'Task bearbeiten' : 'Neue Task erstellen'}</DialogTitle>
|
||||
<DialogTitle>{editingTask ? t('form.editTitle') : t('form.createTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-title">Titel</Label>
|
||||
<Label htmlFor="task-title">{t('form.title')}</Label>
|
||||
<Input
|
||||
id="task-title"
|
||||
value={form.title}
|
||||
@@ -287,17 +290,17 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-description">Beschreibung</Label>
|
||||
<Label htmlFor="task-description">{t('form.description')}</Label>
|
||||
<Input
|
||||
id="task-description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="Was sollen Gäste machen?"
|
||||
placeholder={t('form.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-priority">Priorität</Label>
|
||||
<Label htmlFor="task-priority">{t('form.priority')}</Label>
|
||||
<Select
|
||||
value={form.priority ?? 'medium'}
|
||||
onValueChange={(value) =>
|
||||
@@ -305,18 +308,18 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="task-priority">
|
||||
<SelectValue placeholder="Priorität wählen" />
|
||||
<SelectValue placeholder={t('form.priorityPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Niedrig</SelectItem>
|
||||
<SelectItem value="medium">Mittel</SelectItem>
|
||||
<SelectItem value="high">Hoch</SelectItem>
|
||||
<SelectItem value="urgent">Dringend</SelectItem>
|
||||
<SelectItem value="low">{t('priorities.low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('priorities.medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('priorities.high')}</SelectItem>
|
||||
<SelectItem value="urgent">{t('priorities.urgent')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-due-date">Fälligkeitsdatum</Label>
|
||||
<Label htmlFor="task-due-date">{t('form.dueDate')}</Label>
|
||||
<Input
|
||||
id="task-due-date"
|
||||
type="date"
|
||||
@@ -328,19 +331,19 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Bereits erledigt?</p>
|
||||
<p className="text-xs text-slate-500">Markiere Aufgaben als abgeschlossen, wenn sie nicht mehr sichtbar sein sollen.</p>
|
||||
<p className="text-sm font-medium text-slate-700">{t('form.completedTitle')}</p>
|
||||
<p className="text-xs text-slate-500">{t('form.completedCopy')}</p>
|
||||
</div>
|
||||
<Switch checked={form.is_completed} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: checked }))} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Abbrechen
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Speichern
|
||||
{t('form.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -353,10 +356,11 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
export default function TasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t: tc } = useTranslation('common');
|
||||
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
|
||||
return (
|
||||
<AdminLayout
|
||||
title={tc('navigation.tasks')}
|
||||
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
|
||||
subtitle={t('subtitles.default')}
|
||||
>
|
||||
<TasksSection onNavigateToCollections={() => navigate(buildEngagementTabPath('collections'))} />
|
||||
</AdminLayout>
|
||||
@@ -376,6 +380,7 @@ function TaskRow({
|
||||
}) {
|
||||
const isCompleted = task.is_completed;
|
||||
const statusIcon = isCompleted ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Circle className="h-4 w-4 text-slate-300" />;
|
||||
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200/70 bg-white/80 p-4 shadow-sm shadow-pink-100/20 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -386,7 +391,7 @@ function TaskRow({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-900">{task.title}</span>
|
||||
{task.priority ? <PriorityBadge priority={task.priority} /> : null}
|
||||
{task.collection_id ? <Badge variant="secondary">Vorlage #{task.collection_id}</Badge> : null}
|
||||
{task.collection_id ? <Badge variant="secondary">{t('list.template', { id: task.collection_id })}</Badge> : null}
|
||||
</div>
|
||||
{task.description ? <p className="text-xs text-slate-500">{task.description}</p> : null}
|
||||
</div>
|
||||
@@ -394,11 +399,11 @@ function TaskRow({
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
Bearbeiten
|
||||
{t('list.edit')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onDelete} className="text-slate-500 hover:text-rose-500">
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
Löschen
|
||||
{t('list.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,11 +411,12 @@ function TaskRow({
|
||||
}
|
||||
|
||||
function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priority']> }) {
|
||||
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
|
||||
const mapping: Record<NonNullable<TaskPayload['priority']>, { label: string; className: string }> = {
|
||||
low: { label: 'Niedrig', className: 'bg-emerald-50 text-emerald-600' },
|
||||
medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
|
||||
high: { label: 'Hoch', className: 'bg-rose-50 text-rose-600' },
|
||||
urgent: { label: 'Dringend', className: 'bg-red-50 text-red-600' },
|
||||
low: { label: t('priorities.low'), className: 'bg-emerald-50 text-emerald-600' },
|
||||
medium: { label: t('priorities.medium'), className: 'bg-amber-50 text-amber-600' },
|
||||
high: { label: t('priorities.high'), className: 'bg-rose-50 text-rose-600' },
|
||||
urgent: { label: t('priorities.urgent'), className: 'bg-red-50 text-red-600' },
|
||||
};
|
||||
const { label, className } = mapping[priority];
|
||||
return <Badge className={`border-none ${className}`}>{label}</Badge>;
|
||||
@@ -427,15 +433,14 @@ function TasksSkeleton() {
|
||||
}
|
||||
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary.empty' });
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
|
||||
<h3 className="text-base font-semibold text-slate-800">Noch keine Tasks angelegt</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Starte mit einer neuen Aufgabe oder importiere Aufgabenvorlagen, um deine Gäste zu inspirieren.
|
||||
</p>
|
||||
<h3 className="text-base font-semibold text-slate-800">{t('title')}</h3>
|
||||
<p className="text-sm text-slate-500">{t('description')}</p>
|
||||
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Erste Task erstellen
|
||||
{t('cta')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,10 @@ vi.mock('../../onboarding', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventContext', () => ({
|
||||
useEventContext: () => ({ events: [], activeEvent: null, selectEvent: vi.fn(), isLoading: false, isError: false, refetch: vi.fn() }),
|
||||
}), { virtual: true });
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
getDashboardSummary: vi.fn().mockResolvedValue(null),
|
||||
getEvents: vi.fn().mockResolvedValue([]),
|
||||
@@ -55,7 +59,7 @@ describe('DashboardPage onboarding guard', () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalledWith(ADMIN_WELCOME_BASE_PATH, { replace: true });
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(markStepMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
|
||||
const EventFormPage = React.lazy(() => import('./pages/EventFormPage'));
|
||||
const EventPhotosPage = React.lazy(() => import('./pages/EventPhotosPage'));
|
||||
const EventDetailPage = React.lazy(() => import('./pages/EventDetailPage'));
|
||||
const EventRecapPage = React.lazy(() => import('./pages/EventRecapPage'));
|
||||
const EventMembersPage = React.lazy(() => import('./pages/EventMembersPage'));
|
||||
const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage'));
|
||||
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
|
||||
@@ -102,7 +103,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events', element: <EventsPage /> },
|
||||
{ path: 'events/new', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug', element: <EventDetailPage /> },
|
||||
{ path: 'events/:slug/recap', element: <EventDetailPage /> },
|
||||
{ path: 'events/:slug/recap', element: <EventRecapPage /> },
|
||||
{ path: 'events/:slug/edit', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
|
||||
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
|
||||
|
||||
@@ -7,7 +7,10 @@ import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog';
|
||||
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '../services/galleryApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
|
||||
import { AlertTriangle, Download, Loader2, X } from 'lucide-react';
|
||||
import { AlertTriangle, Download, Loader2, Share, X } from 'lucide-react';
|
||||
import { createPhotoShareLink } from '../services/photosApi';
|
||||
import { Share } from 'lucide-react';
|
||||
import { createPhotoShareLink } from '../services/photosApi';
|
||||
import { getContrastingTextColor } from '../lib/color';
|
||||
|
||||
interface GalleryState {
|
||||
@@ -38,6 +41,7 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
||||
const [state, setState] = useState<GalleryState>(INITIAL_STATE);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<GalleryPhotoResource | null>(null);
|
||||
const [shareLoading, setShareLoading] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale';
|
||||
@@ -353,14 +357,47 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedPhoto?.likes_count ? `${selectedPhoto.likes_count} ❤` : ''}
|
||||
</div>
|
||||
{selectedPhoto?.download_url && (
|
||||
<Button asChild className="gap-2" style={accentStyle}>
|
||||
<a href={selectedPhoto.download_url} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="h-4 w-4" aria-hidden />
|
||||
{t('galleryPublic.download')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(state.meta?.event?.guest_downloads_enabled ?? true) && selectedPhoto?.download_url ? (
|
||||
<Button asChild className="gap-2" style={accentStyle}>
|
||||
<a href={selectedPhoto.download_url} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="h-4 w-4" aria-hidden />
|
||||
{t('galleryPublic.download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{(state.meta?.event?.guest_sharing_enabled ?? true) && selectedPhoto ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={shareLoading}
|
||||
onClick={async () => {
|
||||
if (!token || !selectedPhoto) return;
|
||||
setShareLoading(true);
|
||||
try {
|
||||
const payload = await createPhotoShareLink(token, selectedPhoto.id);
|
||||
const shareData: ShareData = {
|
||||
title: selectedPhoto.guest_name ?? t('share.title', 'Geteiltes Foto'),
|
||||
text: t('share.shareText', { event: state.meta?.event?.name ?? 'Fotospiel' }),
|
||||
url: payload.url,
|
||||
};
|
||||
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||
await navigator.share(shareData).catch(() => undefined);
|
||||
} else if (payload.url) {
|
||||
await navigator.clipboard.writeText(payload.url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('share failed', err);
|
||||
} finally {
|
||||
setShareLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shareLoading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : <Share className="h-4 w-4" aria-hidden />}
|
||||
{t('share.shareCta', 'Teilen')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface GalleryMetaResponse {
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
gallery_expires_at?: string | null;
|
||||
guest_downloads_enabled?: boolean;
|
||||
guest_sharing_enabled?: boolean;
|
||||
};
|
||||
branding: GalleryBranding;
|
||||
}
|
||||
|
||||
@@ -144,4 +144,19 @@ return [
|
||||
'action' => 'Event-Dashboard öffnen',
|
||||
],
|
||||
],
|
||||
|
||||
'tenant_feedback' => [
|
||||
'subject' => 'Neues Feedback: :tenant (:sentiment)',
|
||||
'unknown_tenant' => 'Unbekannter Tenant',
|
||||
'unknown' => 'k.A.',
|
||||
'tenant' => 'Tenant: :tenant',
|
||||
'category' => 'Kategorie: :category',
|
||||
'sentiment' => 'Stimmung: :sentiment',
|
||||
'event' => 'Event: :event',
|
||||
'rating' => 'Rating: :rating',
|
||||
'title' => 'Betreff: :subject',
|
||||
'message' => 'Nachricht:',
|
||||
'open' => 'Feedback im Super Admin öffnen',
|
||||
'received_at' => 'Eingegangen: :date',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -144,4 +144,19 @@ return [
|
||||
'action' => 'Open event dashboard',
|
||||
],
|
||||
],
|
||||
|
||||
'tenant_feedback' => [
|
||||
'subject' => 'New feedback: :tenant (:sentiment)',
|
||||
'unknown_tenant' => 'Unknown tenant',
|
||||
'unknown' => 'N/A',
|
||||
'tenant' => 'Tenant: :tenant',
|
||||
'category' => 'Category: :category',
|
||||
'sentiment' => 'Sentiment: :sentiment',
|
||||
'event' => 'Event: :event',
|
||||
'rating' => 'Rating: :rating',
|
||||
'title' => 'Subject: :subject',
|
||||
'message' => 'Message:',
|
||||
'open' => 'Open feedback in Super Admin',
|
||||
'received_at' => 'Received: :date',
|
||||
],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user