coupon code system eingeführt. coupons werden vom super admin gemanaged. coupons werden mit paddle synchronisiert und dort validiert. plus: einige mobil-optimierungen im tenant admin pwa.

This commit is contained in:
Codex Agent
2025-11-09 20:26:50 +01:00
parent f3c44be76d
commit 082b78cd43
80 changed files with 4855 additions and 435 deletions

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Console\Commands;
use App\Models\CouponRedemption;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ExportCouponRedemptions extends Command
{
protected $signature = 'coupons:export {--days=30 : Number of days to include} {--path= : Relative path inside storage/app}';
protected $description = 'Export coupon redemptions within the given timeframe to CSV.';
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
$since = now()->subDays($days);
$redemptions = CouponRedemption::query()
->where('status', CouponRedemption::STATUS_SUCCESS)
->where('redeemed_at', '>=', $since)
->with(['coupon', 'tenant', 'package'])
->orderBy('redeemed_at')
->get();
$rows = [
['coupon_code', 'tenant_id', 'package_id', 'amount_discounted', 'currency', 'redeemed_at'],
];
foreach ($redemptions as $redemption) {
$rows[] = [
$redemption->coupon?->code ?? '',
$redemption->tenant_id,
$redemption->package_id,
number_format((float) $redemption->amount_discounted, 2, '.', ''),
$redemption->currency,
optional($redemption->redeemed_at)->toIso8601String(),
];
}
$csv = collect($rows)
->map(fn (array $row) => collect($row)
->map(fn ($value) => '"'.str_replace('"', '""', (string) $value).'"')
->implode(','))
->implode(PHP_EOL)
.PHP_EOL;
$path = $this->option('path') ?: 'reports/coupon-redemptions-'.now()->format('Ymd_His').'.csv';
Storage::disk('local')->put($path, $csv);
$this->info(sprintf('Exported %d records to %s', $redemptions->count(), storage_path('app/'.$path)));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Enums;
enum CouponStatus: string
{
case DRAFT = 'draft';
case ACTIVE = 'active';
case SCHEDULED = 'scheduled';
case PAUSED = 'paused';
case ARCHIVED = 'archived';
public function label(): string
{
return match ($this) {
self::DRAFT => __('Draft'),
self::ACTIVE => __('Active'),
self::SCHEDULED => __('Scheduled'),
self::PAUSED => __('Paused'),
self::ARCHIVED => __('Archived'),
};
}
}

19
app/Enums/CouponType.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum CouponType: string
{
case PERCENTAGE = 'percentage';
case FLAT = 'flat';
case FLAT_PER_SEAT = 'flat_per_seat';
public function label(): string
{
return match ($this) {
self::PERCENTAGE => __('Percentage'),
self::FLAT => __('Flat amount'),
self::FLAT_PER_SEAT => __('Flat per seat'),
};
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Filament\Resources\Coupons;
use App\Filament\Resources\Coupons\Pages\CreateCoupon;
use App\Filament\Resources\Coupons\Pages\EditCoupon;
use App\Filament\Resources\Coupons\Pages\ListCoupons;
use App\Filament\Resources\Coupons\Pages\ViewCoupon;
use App\Filament\Resources\Coupons\RelationManagers\RedemptionsRelationManager;
use App\Filament\Resources\Coupons\Schemas\CouponForm;
use App\Filament\Resources\Coupons\Schemas\CouponInfolist;
use App\Filament\Resources\Coupons\Tables\CouponsTable;
use App\Models\Coupon;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Auth;
class CouponResource extends Resource
{
protected static ?string $model = Coupon::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
protected static ?int $navigationSort = 60;
protected static ?string $navigationLabel = 'Coupons';
public static function getNavigationGroup(): string
{
return __('Billing & Finanzen');
}
public static function form(Schema $schema): Schema
{
return CouponForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return CouponInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return CouponsTable::configure($table);
}
public static function getRelations(): array
{
return [
RedemptionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListCoupons::route('/'),
'create' => CreateCoupon::route('/create'),
'view' => ViewCoupon::route('/{record}'),
'edit' => EditCoupon::route('/{record}/edit'),
];
}
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return parent::getRecordRouteBindingEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
public static function mutateFormDataBeforeCreate(array $data): array
{
$userId = Auth::id();
if ($userId) {
$data['created_by'] = $userId;
$data['updated_by'] = $userId;
}
return static::sanitizeFormData($data);
}
public static function mutateFormDataBeforeSave(array $data): array
{
if ($userId = Auth::id()) {
$data['updated_by'] = $userId;
}
return static::sanitizeFormData($data);
}
protected static function sanitizeFormData(array $data): array
{
if (! empty($data['code'])) {
$data['code'] = strtoupper($data['code']);
}
if (($data['type'] ?? null) === 'percentage') {
$data['currency'] = null;
} elseif (! empty($data['currency'])) {
$data['currency'] = strtoupper($data['currency']);
}
if (empty($data['metadata'])) {
$data['metadata'] = null;
}
return $data;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource;
use App\Jobs\SyncCouponToPaddle;
use Filament\Resources\Pages\CreateRecord;
class CreateCoupon extends CreateRecord
{
protected static string $resource = CouponResource::class;
protected function afterCreate(): void
{
SyncCouponToPaddle::dispatch($this->record);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource;
use App\Jobs\SyncCouponToPaddle;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditCoupon extends EditRecord
{
protected static string $resource = CouponResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make()
->after(fn ($record) => SyncCouponToPaddle::dispatch($record, true)),
ForceDeleteAction::make(),
RestoreAction::make(),
];
}
protected function afterSave(): void
{
SyncCouponToPaddle::dispatch($this->record);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListCoupons extends ListRecords
{
protected static string $resource = CouponResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewCoupon extends ViewRecord
{
protected static string $resource = CouponResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Filament\Resources\Coupons\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class RedemptionsRelationManager extends RelationManager
{
protected static string $relationship = 'redemptions';
public function form(Schema $schema): Schema
{
return $schema;
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('paddle_transaction_id')
->columns([
TextColumn::make('tenant.name')
->label(__('Tenant'))
->searchable(),
TextColumn::make('user.name')
->label(__('User'))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('amount_discounted')
->label(__('Discount'))
->formatStateUsing(fn ($state, $record) => sprintf('%s %s', number_format($record->amount_discounted, 2), $record->currency))
->sortable(),
TextColumn::make('status')
->label(__('Status'))
->badge()
->color(fn ($state) => match ($state) {
'success' => 'success',
'failed' => 'danger',
default => 'warning',
}),
TextColumn::make('paddle_transaction_id')
->label(__('Transaction'))
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('redeemed_at')
->label(__('Redeemed at'))
->dateTime()
->sortable(),
TextColumn::make('metadata')
->label(__('Metadata'))
->formatStateUsing(fn ($state) => $state ? json_encode($state, JSON_PRETTY_PRINT) : '—')
->toggleable(isToggledHiddenByDefault: true)
->copyable(),
])
->filters([
SelectFilter::make('status')
->label(__('Status'))
->options([
'pending' => __('Pending'),
'success' => __('Success'),
'failed' => __('Failed'),
]),
])
->headerActions([
// read-only
])
->recordActions([])
->toolbarActions([]);
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Filament\Resources\Coupons\Schemas;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Validation\Rules\Unique;
class CouponForm
{
public static function configure(Schema $schema): Schema
{
$typeOptions = collect(CouponType::cases())
->mapWithKeys(fn (CouponType $type) => [$type->value => $type->label()])
->all();
$statusOptions = collect(CouponStatus::cases())
->mapWithKeys(fn (CouponStatus $status) => [$status->value => $status->label()])
->all();
return $schema
->columns(1)
->components([
Section::make(__('Coupon details'))
->columns(3)
->schema([
TextInput::make('name')
->label(__('Name'))
->maxLength(191)
->required(),
TextInput::make('code')
->label(__('Code'))
->maxLength(32)
->required()
->helperText(__('Codes are stored uppercase and must be unique.'))
->unique(ignoreRecord: true, modifyRuleUsing: fn (Unique $rule) => $rule->withoutTrashed())
->rule('alpha_dash:ascii'),
Select::make('status')
->label(__('Status'))
->options($statusOptions)
->default(CouponStatus::DRAFT)
->required(),
Select::make('type')
->label(__('Discount Type'))
->options($typeOptions)
->default(CouponType::PERCENTAGE)
->required(),
TextInput::make('amount')
->label(__('Amount'))
->numeric()
->minValue(0)
->step(0.01)
->required(),
TextInput::make('currency')
->label(__('Currency'))
->maxLength(3)
->default('EUR')
->helperText(__('Required for flat discounts. Hidden for percentage codes.'))
->disabled(fn (Get $get) => $get('type') === CouponType::PERCENTAGE->value)
->nullable(),
Textarea::make('description')
->label(__('Description'))
->rows(3)
->columnSpanFull(),
Toggle::make('enabled_for_checkout')
->label(__('Can be redeemed at checkout'))
->default(true),
Toggle::make('auto_apply')
->label(__('Auto apply when query parameter matches'))
->helperText(__('Automatically applies when marketing links include ?coupon=CODE.')),
Toggle::make('is_stackable')
->label(__('Allow stacking with other coupons'))
->default(false),
]),
Section::make(__('Limits & Scheduling'))
->columns(2)
->schema([
DateTimePicker::make('starts_at')
->label(__('Starts at'))
->seconds(false)
->nullable(),
DateTimePicker::make('ends_at')
->label(__('Ends at'))
->seconds(false)
->nullable(),
TextInput::make('usage_limit')
->label(__('Global usage limit'))
->numeric()
->minValue(1)
->nullable(),
TextInput::make('per_customer_limit')
->label(__('Per tenant limit'))
->numeric()
->minValue(1)
->nullable(),
]),
Section::make(__('Package restrictions'))
->schema([
Select::make('packages')
->label(__('Applicable packages'))
->relationship('packages', 'name')
->multiple()
->preload()
->searchable()
->helperText(__('Leave empty to allow all packages.')),
]),
Section::make(__('Metadata'))
->schema([
KeyValue::make('metadata')
->label(__('Internal metadata'))
->keyLabel(__('Key'))
->valueLabel(__('Value'))
->nullable()
->columnSpanFull(),
]),
Section::make(__('Paddle sync'))
->columns(2)
->schema([
Select::make('paddle_mode')
->label(__('Paddle mode'))
->options([
'standard' => __('Standard'),
'custom' => __('Custom (one-off)'),
])
->default('standard'),
Placeholder::make('paddle_discount_id')
->label(__('Paddle Discount ID'))
->content(fn ($record) => $record?->paddle_discount_id ?? '—'),
Placeholder::make('paddle_last_synced_at')
->label(__('Last synced'))
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
Placeholder::make('redemptions_count')
->label(__('Total redemptions'))
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
]),
]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Filament\Resources\Coupons\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 CouponInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('Summary'))
->columns(3)
->schema([
TextEntry::make('name')->label(__('Name')),
TextEntry::make('code')->label(__('Code'))->copyable(),
TextEntry::make('status')
->label(__('Status'))
->badge()
->formatStateUsing(fn ($state) => Str::headline($state)),
TextEntry::make('type')
->label(__('Discount type'))
->badge()
->formatStateUsing(fn ($state) => Str::headline($state)),
TextEntry::make('amount')
->label(__('Amount'))
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage'
? sprintf('%s%%', $record?->amount ?? $state)
: sprintf('%s %s', number_format((float) ($record?->amount ?? $state), 2), strtoupper($record?->currency ?? 'EUR'))),
TextEntry::make('redemptions_count')
->label(__('Total redemptions'))
->badge(),
]),
Section::make(__('Limits & scheduling'))
->columns(2)
->schema([
TextEntry::make('usage_limit')->label(__('Global usage limit'))->placeholder('—'),
TextEntry::make('per_customer_limit')->label(__('Per tenant limit'))->placeholder('—'),
TextEntry::make('starts_at')
->label(__('Starts'))
->dateTime(),
TextEntry::make('ends_at')
->label(__('Ends'))
->dateTime(),
]),
Section::make(__('Packages'))
->schema([
TextEntry::make('packages_list')
->label(__('Limited to packages'))
->state(fn ($record) => $record?->packages?->pluck('name')->all() ?: null)
->listWithLineBreaks()
->placeholder(__('All packages')),
]),
Section::make(__('Metadata'))
->schema([
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
]),
Section::make(__('Paddle'))
->columns(3)
->schema([
TextEntry::make('paddle_discount_id')
->label(__('Discount ID'))
->copyable()
->placeholder('—'),
TextEntry::make('paddle_last_synced_at')
->label(__('Last synced'))
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
TextEntry::make('paddle_mode')
->label(__('Mode'))
->badge()
->placeholder('standard'),
]),
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Filament\Resources\Coupons\Tables;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use App\Jobs\SyncCouponToPaddle;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class CouponsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('Name'))
->searchable()
->sortable(),
TextColumn::make('code')
->label(__('Code'))
->badge()
->copyable()
->sortable(),
TextColumn::make('type')
->label(__('Type'))
->badge()
->formatStateUsing(fn ($state) => Str::headline($state))
->sortable(),
TextColumn::make('amount')
->label(__('Amount'))
->formatStateUsing(fn ($state, $record) => $record->type === CouponType::PERCENTAGE
? sprintf('%s%%', $record->amount)
: sprintf('%s %s', number_format($record->amount, 2), strtoupper($record->currency ?? 'EUR')))
->sortable(),
TextColumn::make('redemptions_count')
->label(__('Redeemed'))
->badge()
->sortable(),
IconColumn::make('enabled_for_checkout')
->label(__('Checkout'))
->boolean(),
TextColumn::make('status')
->label(__('Status'))
->badge()
->sortable()
->formatStateUsing(fn ($state) => Str::headline($state)),
TextColumn::make('starts_at')
->label(__('Starts'))
->date()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('ends_at')
->label(__('Ends'))
->date()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('Status'))
->options(
collect(CouponStatus::cases())->mapWithKeys(fn ($status) => [$status->value => $status->label()])->all()
),
SelectFilter::make('type')
->label(__('Type'))
->options(
collect(CouponType::cases())->mapWithKeys(fn ($type) => [$type->value => $type->label()])->all()
),
TernaryFilter::make('is_active')
->label(__('Currently active'))
->placeholder(__('All'))
->queries(
true: fn ($query) => $query->active(),
false: fn ($query) => $query->where(function ($inner) {
$inner->where('status', '!=', CouponStatus::ACTIVE->value)
->orWhere(function ($sub) {
$sub->whereNotNull('ends_at')
->where('ends_at', '<', now());
});
}),
),
TrashedFilter::make(),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
Action::make('sync')
->label(__('Sync to Paddle'))
->icon('heroicon-m-arrow-path')
->action(fn ($record) => SyncCouponToPaddle::dispatch($record))
->requiresConfirmation(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Coupon;
use App\Models\CouponRedemption;
use Filament\Tables\Columns\TextColumn;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Database\Eloquent\Builder;
class CouponUsageWidget extends BaseWidget
{
protected static ?int $sort = 7;
protected function getHeading(): string
{
return __('Coupon performance (30d)');
}
protected function getTableQuery(): Builder
{
$since = now()->subDays(30);
return Coupon::query()
->withCount(['redemptions as recent_redemptions_count' => function (Builder $query) use ($since) {
$query->where('status', CouponRedemption::STATUS_SUCCESS)
->where('redeemed_at', '>=', $since);
}])
->withSum(['redemptions as recent_discount_sum' => function (Builder $query) use ($since) {
$query->where('status', CouponRedemption::STATUS_SUCCESS)
->where('redeemed_at', '>=', $since);
}], 'amount_discounted')
->orderByDesc('recent_discount_sum')
->limit(5);
}
protected function getTableColumns(): array
{
return [
TextColumn::make('code')
->label(__('Code'))
->badge(),
TextColumn::make('status')
->label(__('Status'))
->badge(),
TextColumn::make('recent_redemptions_count')
->label(__('Uses (30d)'))
->sortable(),
TextColumn::make('recent_discount_sum')
->label(__('Discount (30d)'))
->formatStateUsing(function ($state, Coupon $record) {
$currency = $record->currency ?? 'EUR';
$value = (float) ($state ?? 0);
return sprintf('%s %0.2f', $currency, $value);
})
->sortable(),
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Api\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Package;
use App\Services\Coupons\CouponService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class CouponPreviewController extends Controller
{
public function __construct(private readonly CouponService $coupons) {}
public function __invoke(Request $request): JsonResponse
{
$data = $request->validate([
'code' => ['required', 'string', 'max:64'],
'package_id' => ['required', 'integer', 'exists:packages,id'],
]);
$package = Package::findOrFail($data['package_id']);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.package_not_configured'),
]);
}
$tenant = Auth::user()?->tenant;
try {
$preview = $this->coupons->preview($data['code'], $package, $tenant);
} catch (ValidationException $exception) {
Log::warning('Coupon preview denied', [
'code' => $data['code'],
'package_id' => $package->id,
'tenant_id' => $tenant?->id,
'errors' => $exception->errors(),
]);
throw $exception;
}
Log::info('Coupon preview success', [
'code' => $preview['coupon']->code,
'package_id' => $package->id,
'tenant_id' => $tenant?->id,
'discount' => $preview['pricing']['discount'] ?? null,
]);
return response()->json([
'coupon' => [
'id' => $preview['coupon']->id,
'code' => $preview['coupon']->code,
'type' => $preview['coupon']->type?->value,
'amount' => (float) $preview['coupon']->amount,
'currency' => $preview['coupon']->currency,
'description' => $preview['coupon']->description,
'expires_at' => $preview['coupon']->ends_at?->toIso8601String(),
'is_stackable' => $preview['coupon']->is_stackable,
],
'pricing' => $preview['pricing'],
'package' => [
'id' => $package->id,
'name' => $package->name,
'price' => (float) $package->price,
'currency' => $package->currency ?? 'EUR',
],
'source' => $preview['source'],
]);
}
}

View File

@@ -7,6 +7,8 @@ use App\Http\Requests\Tenant\EmotionStoreRequest;
use App\Http\Requests\Tenant\EmotionUpdateRequest; use App\Http\Requests\Tenant\EmotionUpdateRequest;
use App\Http\Resources\Tenant\EmotionResource; use App\Http\Resources\Tenant\EmotionResource;
use App\Models\Emotion; use App\Models\Emotion;
use App\Models\Tenant;
use App\Support\TenantRequestResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -16,7 +18,7 @@ class EmotionController extends Controller
{ {
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
$tenantId = $request->tenant->id; $tenantId = $this->currentTenant($request)->id;
$query = Emotion::query() $query = Emotion::query()
->whereNull('tenant_id') ->whereNull('tenant_id')
@@ -41,9 +43,10 @@ class EmotionController extends Controller
public function store(EmotionStoreRequest $request): JsonResponse public function store(EmotionStoreRequest $request): JsonResponse
{ {
$data = $request->validated(); $data = $request->validated();
$tenantId = $this->currentTenant($request)->id;
$payload = [ $payload = [
'tenant_id' => $request->tenant->id, 'tenant_id' => $tenantId,
'name' => $this->localizeValue($data['name']), 'name' => $this->localizeValue($data['name']),
'description' => $this->localizeValue($data['description'] ?? null, allowNull: true), 'description' => $this->localizeValue($data['description'] ?? null, allowNull: true),
'icon' => $data['icon'] ?? 'lucide-smile', 'icon' => $data['icon'] ?? 'lucide-smile',
@@ -70,7 +73,9 @@ class EmotionController extends Controller
public function update(EmotionUpdateRequest $request, Emotion $emotion): JsonResponse public function update(EmotionUpdateRequest $request, Emotion $emotion): JsonResponse
{ {
if ($emotion->tenant_id && $emotion->tenant_id !== $request->tenant->id) { $tenantId = $this->currentTenant($request)->id;
if ($emotion->tenant_id && $emotion->tenant_id !== $tenantId) {
abort(403, 'Emotion gehört nicht zu diesem Tenant.'); abort(403, 'Emotion gehört nicht zu diesem Tenant.');
} }
@@ -139,6 +144,7 @@ class EmotionController extends Controller
if (is_string($value) && $value !== '') { if (is_string($value) && $value !== '') {
$locale = app()->getLocale() ?: 'de'; $locale = app()->getLocale() ?: 'de';
return [$locale => $value]; return [$locale => $value];
} }
@@ -149,9 +155,14 @@ class EmotionController extends Controller
{ {
$normalized = ltrim($color, '#'); $normalized = ltrim($color, '#');
if (strlen($normalized) === 6) { if (strlen($normalized) === 6) {
return '#' . strtolower($normalized); return '#'.strtolower($normalized);
} }
return '#6366f1'; return '#6366f1';
} }
protected function currentTenant(Request $request): Tenant
{
return TenantRequestResolver::resolve($request);
}
} }

View File

@@ -204,7 +204,7 @@ class SettingsController extends Controller
} }
$taken = Tenant::where('custom_domain', $domain) $taken = Tenant::where('custom_domain', $domain)
->where('id', '!=', $request->tenant->id) ->where('id', '!=', $this->resolveTenant($request)->id)
->exists(); ->exists();
return response()->json([ return response()->json([

View File

@@ -6,17 +6,19 @@ use App\Http\Controllers\Controller;
use App\Http\Resources\Tenant\TaskCollectionResource; use App\Http\Resources\Tenant\TaskCollectionResource;
use App\Models\Event; use App\Models\Event;
use App\Models\TaskCollection; use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Services\Tenant\TaskCollectionImportService; use App\Services\Tenant\TaskCollectionImportService;
use App\Support\TenantRequestResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class TaskCollectionController extends Controller class TaskCollectionController extends Controller
{ {
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
$tenantId = $request->tenant->id; $tenantId = $this->currentTenant($request)->id;
$query = TaskCollection::query() $query = TaskCollection::query()
->forTenant($tenantId) ->forTenant($tenantId)
@@ -68,11 +70,11 @@ class TaskCollectionController extends Controller
$this->authorizeAccess($request, $collection); $this->authorizeAccess($request, $collection);
$data = $request->validate([ $data = $request->validate([
'event_slug' => ['required', 'string', Rule::exists('events', 'slug')->where('tenant_id', $request->tenant->id)], 'event_slug' => ['required', 'string', Rule::exists('events', 'slug')->where('tenant_id', $this->currentTenant($request)->id)],
]); ]);
$event = Event::where('slug', $data['event_slug']) $event = Event::where('slug', $data['event_slug'])
->where('tenant_id', $request->tenant->id) ->where('tenant_id', $this->currentTenant($request)->id)
->firstOrFail(); ->firstOrFail();
$result = $importService->import($collection, $event); $result = $importService->import($collection, $event);
@@ -87,8 +89,13 @@ class TaskCollectionController extends Controller
protected function authorizeAccess(Request $request, TaskCollection $collection): void protected function authorizeAccess(Request $request, TaskCollection $collection): void
{ {
if ($collection->tenant_id && $collection->tenant_id !== $request->tenant->id) { if ($collection->tenant_id && $collection->tenant_id !== $this->currentTenant($request)->id) {
abort(404); abort(404);
} }
} }
protected function currentTenant(Request $request): Tenant
{
return TenantRequestResolver::resolve($request);
}
} }

View File

@@ -9,7 +9,9 @@ use App\Http\Resources\Tenant\TaskResource;
use App\Models\Event; use App\Models\Event;
use App\Models\Task; use App\Models\Task;
use App\Models\TaskCollection; use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantRequestResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -22,14 +24,14 @@ class TaskController extends Controller
*/ */
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
$tenantId = $request->tenant->id; $tenantId = $this->currentTenant($request)->id;
$query = Task::query() $query = Task::query()
->where(function ($inner) use ($tenantId) { ->where(function ($inner) use ($tenantId) {
$inner->whereNull('tenant_id') $inner->whereNull('tenant_id')
->orWhere('tenant_id', $tenantId); ->orWhere('tenant_id', $tenantId);
}) })
->with(['taskCollection', 'assignedEvents']) ->with(['taskCollection', 'assignedEvents', 'eventType'])
->orderByRaw('tenant_id is null desc') ->orderByRaw('tenant_id is null desc')
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
@@ -64,11 +66,12 @@ class TaskController extends Controller
*/ */
public function store(TaskStoreRequest $request): JsonResponse public function store(TaskStoreRequest $request): JsonResponse
{ {
$tenant = $this->currentTenant($request);
$collectionId = $request->input('collection_id'); $collectionId = $request->input('collection_id');
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null; $collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
$payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id); $payload = $this->prepareTaskPayload($request->validated(), $tenant->id);
$payload['tenant_id'] = $request->tenant->id; $payload['tenant_id'] = $tenant->id;
if ($collection) { if ($collection) {
$payload['collection_id'] = $collection->id; $payload['collection_id'] = $collection->id;
@@ -77,7 +80,7 @@ class TaskController extends Controller
$task = Task::create($payload); $task = Task::create($payload);
$task->load(['taskCollection', 'assignedEvents']); $task->load(['taskCollection', 'assignedEvents', 'eventType']);
return response()->json([ return response()->json([
'message' => 'Task erfolgreich erstellt.', 'message' => 'Task erfolgreich erstellt.',
@@ -90,11 +93,11 @@ class TaskController extends Controller
*/ */
public function show(Request $request, Task $task): JsonResponse public function show(Request $request, Task $task): JsonResponse
{ {
if ($task->tenant_id && $task->tenant_id !== $request->tenant->id) { if ($task->tenant_id && $task->tenant_id !== $this->currentTenant($request)->id) {
abort(404, 'Task nicht gefunden.'); abort(404, 'Task nicht gefunden.');
} }
$task->load(['taskCollection', 'assignedEvents']); $task->load(['taskCollection', 'assignedEvents', 'eventType']);
return response()->json(new TaskResource($task)); return response()->json(new TaskResource($task));
} }
@@ -104,14 +107,16 @@ class TaskController extends Controller
*/ */
public function update(TaskUpdateRequest $request, Task $task): JsonResponse public function update(TaskUpdateRequest $request, Task $task): JsonResponse
{ {
if ($task->tenant_id !== $request->tenant->id) { $tenant = $this->currentTenant($request);
if ($task->tenant_id !== $tenant->id) {
abort(404, 'Task nicht gefunden.'); abort(404, 'Task nicht gefunden.');
} }
$collectionId = $request->input('collection_id'); $collectionId = $request->input('collection_id');
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null; $collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
$payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id, $task); $payload = $this->prepareTaskPayload($request->validated(), $tenant->id, $task);
if ($collection) { if ($collection) {
$payload['collection_id'] = $collection->id; $payload['collection_id'] = $collection->id;
@@ -133,7 +138,7 @@ class TaskController extends Controller
*/ */
public function destroy(Request $request, Task $task): JsonResponse public function destroy(Request $request, Task $task): JsonResponse
{ {
if ($task->tenant_id !== $request->tenant->id) { if ($task->tenant_id !== $this->currentTenant($request)->id) {
abort(404, 'Task nicht gefunden.'); abort(404, 'Task nicht gefunden.');
} }
@@ -149,7 +154,9 @@ class TaskController extends Controller
*/ */
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
{ {
if ($task->tenant_id !== $request->tenant->id || $event->tenant_id !== $request->tenant->id) { $tenantId = $this->currentTenant($request)->id;
if ($task->tenant_id !== $tenantId || $event->tenant_id !== $tenantId) {
abort(404); abort(404);
} }
@@ -169,7 +176,9 @@ class TaskController extends Controller
*/ */
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
{ {
if ($event->tenant_id !== $request->tenant->id) { $tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {
abort(404); abort(404);
} }
@@ -184,7 +193,7 @@ class TaskController extends Controller
} }
$tasks = Task::whereIn('id', $taskIds) $tasks = Task::whereIn('id', $taskIds)
->where('tenant_id', $request->tenant->id) ->where('tenant_id', $tenantId)
->get(); ->get();
$attached = 0; $attached = 0;
@@ -205,12 +214,12 @@ class TaskController extends Controller
*/ */
public function forEvent(Request $request, Event $event): AnonymousResourceCollection public function forEvent(Request $request, Event $event): AnonymousResourceCollection
{ {
if ($event->tenant_id !== $request->tenant->id) { if ($event->tenant_id !== $this->currentTenant($request)->id) {
abort(404); abort(404);
} }
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id)) $tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
->with(['taskCollection']) ->with(['taskCollection', 'eventType'])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 15)); ->paginate($request->get('per_page', 15));
@@ -222,12 +231,12 @@ class TaskController extends Controller
*/ */
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
{ {
if ($collection->tenant_id && $collection->tenant_id !== $request->tenant->id) { if ($collection->tenant_id && $collection->tenant_id !== $this->currentTenant($request)->id) {
abort(404); abort(404);
} }
$tasks = $collection->tasks() $tasks = $collection->tasks()
->with(['assignedEvents']) ->with(['assignedEvents', 'eventType'])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 15)); ->paginate($request->get('per_page', 15));
@@ -240,13 +249,20 @@ class TaskController extends Controller
->where(function ($query) use ($request) { ->where(function ($query) use ($request) {
$query->whereNull('tenant_id'); $query->whereNull('tenant_id');
if ($request->tenant?->id) { $tenantId = $this->currentTenant($request)->id;
$query->orWhere('tenant_id', $request->tenant->id);
if ($tenantId) {
$query->orWhere('tenant_id', $tenantId);
} }
}) })
->firstOrFail(); ->firstOrFail();
} }
protected function currentTenant(Request $request): Tenant
{
return TenantRequestResolver::resolve($request);
}
protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
{ {
if (array_key_exists('title', $data)) { if (array_key_exists('title', $data)) {

View File

@@ -9,12 +9,14 @@ use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\Paddle\PaddleCheckoutService; use App\Services\Paddle\PaddleCheckoutService;
use App\Support\Concerns\PresentsPackages; use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
@@ -32,6 +34,7 @@ class MarketingController extends Controller
public function __construct( public function __construct(
private readonly CheckoutSessionService $checkoutSessions, private readonly CheckoutSessionService $checkoutSessions,
private readonly PaddleCheckoutService $paddleCheckout, private readonly PaddleCheckoutService $paddleCheckout,
private readonly CouponService $coupons,
) {} ) {}
public function index() public function index()
@@ -107,8 +110,10 @@ class MarketingController extends Controller
Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]); Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]);
$package = Package::findOrFail($packageId); $package = Package::findOrFail($packageId);
$couponCode = $this->rememberCouponFromRequest($request, $package);
if (! Auth::check()) { if (! Auth::check()) {
return redirect()->route('register', ['package_id' => $package->id]) return redirect()->route('register', ['package_id' => $package->id, 'coupon' => $couponCode])
->with('message', __('marketing.packages.register_required')); ->with('message', __('marketing.packages.register_required'));
} }
@@ -167,6 +172,19 @@ class MarketingController extends Controller
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$appliedDiscountId = null;
if ($couponCode) {
try {
$preview = $this->coupons->preview($couponCode, $package, $tenant);
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
$request->session()->forget('marketing.checkout.coupon');
} catch (ValidationException $exception) {
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
}
}
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [ $checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
'success_url' => route('marketing.success', [ 'success_url' => route('marketing.success', [
'locale' => app()->getLocale(), 'locale' => app()->getLocale(),
@@ -178,7 +196,9 @@ class MarketingController extends Controller
]), ]),
'metadata' => [ 'metadata' => [
'checkout_session_id' => $session->id, 'checkout_session_id' => $session->id,
'coupon_code' => $couponCode,
], ],
'discount_id' => $appliedDiscountId,
]); ]);
$session->forceFill([ $session->forceFill([
@@ -210,6 +230,34 @@ class MarketingController extends Controller
return Inertia::render('marketing/Success', compact('packageId')); return Inertia::render('marketing/Success', compact('packageId'));
} }
protected function rememberCouponFromRequest(Request $request, Package $package): ?string
{
$input = Str::upper(trim((string) $request->input('coupon')));
if ($input !== '') {
$request->session()->put('marketing.checkout.coupon', [
'package_id' => $package->id,
'code' => $input,
]);
return $input;
}
if ($request->has('coupon')) {
$request->session()->forget('marketing.checkout.coupon');
return null;
}
$stored = $request->session()->get('marketing.checkout.coupon');
if ($stored && (int) ($stored['package_id'] ?? 0) === (int) $package->id) {
return $stored['code'] ?? null;
}
return null;
}
public function blogIndex(Request $request) public function blogIndex(Request $request)
{ {
$locale = $request->get('locale', app()->getLocale()); $locale = $request->get('locale', app()->getLocale());

View File

@@ -5,10 +5,12 @@ namespace App\Http\Controllers;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Package; use App\Models\Package;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\Paddle\PaddleCheckoutService; use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class PaddleCheckoutController extends Controller class PaddleCheckoutController extends Controller
@@ -16,6 +18,7 @@ class PaddleCheckoutController extends Controller
public function __construct( public function __construct(
private readonly PaddleCheckoutService $checkout, private readonly PaddleCheckoutService $checkout,
private readonly CheckoutSessionService $sessions, private readonly CheckoutSessionService $sessions,
private readonly CouponService $coupons,
) {} ) {}
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
@@ -25,6 +28,7 @@ class PaddleCheckoutController extends Controller
'success_url' => ['nullable', 'url'], 'success_url' => ['nullable', 'url'],
'return_url' => ['nullable', 'url'], 'return_url' => ['nullable', 'url'],
'inline' => ['sometimes', 'boolean'], 'inline' => ['sometimes', 'boolean'],
'coupon_code' => ['nullable', 'string', 'max:64'],
]); ]);
$user = Auth::user(); $user = Auth::user();
@@ -46,7 +50,16 @@ class PaddleCheckoutController extends Controller
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
if ($request->boolean('inline')) { $couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
$discountId = null;
if ($couponCode !== '') {
$preview = $this->coupons->preview($couponCode, $package, $tenant);
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
$discountId = $preview['coupon']->paddle_discount_id;
}
if ($request->boolean('inline') && $discountId === null) {
$metadata = array_merge($session->provider_metadata ?? [], [ $metadata = array_merge($session->provider_metadata ?? [], [
'mode' => 'inline', 'mode' => 'inline',
]); ]);
@@ -80,7 +93,9 @@ class PaddleCheckoutController extends Controller
'return_url' => $data['return_url'] ?? null, 'return_url' => $data['return_url'] ?? null,
'metadata' => [ 'metadata' => [
'checkout_session_id' => $session->id, 'checkout_session_id' => $session->id,
'coupon_code' => $couponCode ?: null,
], ],
'discount_id' => $discountId,
]); ]);
$session->forceFill([ $session->forceFill([

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Tenant; namespace App\Http\Requests\Tenant;
use App\Support\TenantRequestResolver;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -22,12 +23,12 @@ class TaskStoreRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
$tenantId = TenantRequestResolver::resolve($this)->id;
return [ return [
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) { 'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) use ($tenantId) {
$tenantId = request()->tenant?->id;
$accessible = \App\Models\TaskCollection::where('id', $value) $accessible = \App\Models\TaskCollection::where('id', $value)
->where(function ($query) use ($tenantId) { ->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id'); $query->whereNull('tenant_id');
@@ -45,9 +46,8 @@ class TaskStoreRequest extends FormRequest
'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])], 'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
'due_date' => ['nullable', 'date', 'after:now'], 'due_date' => ['nullable', 'date', 'after:now'],
'is_completed' => ['nullable', 'boolean'], 'is_completed' => ['nullable', 'boolean'],
'assigned_to' => ['nullable', 'exists:users,id', function ($attribute, $value, $fail) { 'assigned_to' => ['nullable', 'exists:users,id', function ($attribute, $value, $fail) use ($tenantId) {
$tenantId = request()->tenant?->id; if ($tenantId && ! \App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
$fail('Der Benutzer gehört nicht zu diesem Tenant.'); $fail('Der Benutzer gehört nicht zu diesem Tenant.');
} }
}], }],

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Tenant; namespace App\Http\Requests\Tenant;
use App\Support\TenantRequestResolver;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -22,12 +23,12 @@ class TaskUpdateRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
$tenantId = TenantRequestResolver::resolve($this)->id;
return [ return [
'title' => ['sometimes', 'required', 'string', 'max:255'], 'title' => ['sometimes', 'required', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string'], 'description' => ['sometimes', 'nullable', 'string'],
'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) { 'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) use ($tenantId) {
$tenantId = request()->tenant?->id;
$accessible = \App\Models\TaskCollection::where('id', $value) $accessible = \App\Models\TaskCollection::where('id', $value)
->where(function ($query) use ($tenantId) { ->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id'); $query->whereNull('tenant_id');
@@ -45,9 +46,8 @@ class TaskUpdateRequest extends FormRequest
'priority' => ['sometimes', 'nullable', Rule::in(['low', 'medium', 'high', 'urgent'])], 'priority' => ['sometimes', 'nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
'due_date' => ['sometimes', 'nullable', 'date'], 'due_date' => ['sometimes', 'nullable', 'date'],
'is_completed' => ['sometimes', 'boolean'], 'is_completed' => ['sometimes', 'boolean'],
'assigned_to' => ['sometimes', 'nullable', 'exists:users,id', function ($attribute, $value, $fail) { 'assigned_to' => ['sometimes', 'nullable', 'exists:users,id', function ($attribute, $value, $fail) use ($tenantId) {
$tenantId = request()->tenant?->id; if ($tenantId && ! \App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
$fail('Der Benutzer gehört nicht zu diesem Tenant.'); $fail('Der Benutzer gehört nicht zu diesem Tenant.');
} }
}], }],

View File

@@ -36,6 +36,11 @@ class TaskResource extends JsonResource
'difficulty' => $this->difficulty, 'difficulty' => $this->difficulty,
'due_date' => $this->due_date?->toISOString(), 'due_date' => $this->due_date?->toISOString(),
'is_completed' => (bool) $this->is_completed, 'is_completed' => (bool) $this->is_completed,
'event_type_id' => $this->event_type_id,
'event_type' => $this->whenLoaded(
'eventType',
fn () => new EventTypeResource($this->eventType)
),
'collection_id' => $this->collection_id, 'collection_id' => $this->collection_id,
'source_task_id' => $this->source_task_id, 'source_task_id' => $this->source_task_id,
'source_collection_id' => $this->source_collection_id, 'source_collection_id' => $this->source_collection_id,

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Jobs;
use App\Models\Coupon;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleDiscountService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SyncCouponToPaddle implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public Coupon $coupon,
public bool $archive = false,
) {}
public function handle(PaddleDiscountService $discounts): void
{
try {
if ($this->archive) {
$discounts->archiveDiscount($this->coupon);
$this->coupon->forceFill([
'paddle_discount_id' => null,
'paddle_snapshot' => null,
'paddle_last_synced_at' => now(),
])->save();
return;
}
$data = $discounts->updateDiscount($this->coupon);
$this->coupon->forceFill([
'paddle_discount_id' => $data['id'] ?? $this->coupon->paddle_discount_id,
'paddle_snapshot' => $data,
'paddle_last_synced_at' => now(),
])->save();
} catch (PaddleException $exception) {
Log::error('Failed syncing coupon to Paddle', [
'coupon_id' => $this->coupon->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
'context' => $exception->context(),
]);
$this->coupon->forceFill([
'paddle_snapshot' => [
'error' => $exception->getMessage(),
'status' => $exception->status(),
'context' => $exception->context(),
],
])->save();
throw $exception;
}
}
}

View File

@@ -58,10 +58,13 @@ class CheckoutSession extends Model
'package_snapshot' => 'array', 'package_snapshot' => 'array',
'status_history' => 'array', 'status_history' => 'array',
'provider_metadata' => 'array', 'provider_metadata' => 'array',
'coupon_snapshot' => 'array',
'discount_breakdown' => 'array',
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'completed_at' => 'datetime', 'completed_at' => 'datetime',
'amount_subtotal' => 'decimal:2', 'amount_subtotal' => 'decimal:2',
'amount_total' => 'decimal:2', 'amount_total' => 'decimal:2',
'amount_discount' => 'decimal:2',
]; ];
/** /**
@@ -71,6 +74,8 @@ class CheckoutSession extends Model
'status_history' => '[]', 'status_history' => '[]',
'package_snapshot' => '[]', 'package_snapshot' => '[]',
'provider_metadata' => '[]', 'provider_metadata' => '[]',
'coupon_snapshot' => '[]',
'discount_breakdown' => '[]',
]; ];
public function user(): BelongsTo public function user(): BelongsTo
@@ -88,6 +93,11 @@ class CheckoutSession extends Model
return $this->belongsTo(Package::class)->withTrashed(); return $this->belongsTo(Package::class)->withTrashed();
} }
public function coupon(): BelongsTo
{
return $this->belongsTo(Coupon::class);
}
public function scopeActive($query) public function scopeActive($query)
{ {
return $query->whereNotIn('status', [ return $query->whereNotIn('status', [

189
app/Models/Coupon.php Normal file
View File

@@ -0,0 +1,189 @@
<?php
namespace App\Models;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Coupon extends Model
{
/** @use HasFactory<\Database\Factories\CouponFactory> */
use HasFactory;
use SoftDeletes;
protected $fillable = [
'name',
'code',
'type',
'amount',
'currency',
'status',
'is_stackable',
'enabled_for_checkout',
'auto_apply',
'usage_limit',
'per_customer_limit',
'redemptions_count',
'description',
'metadata',
'starts_at',
'ends_at',
'paddle_discount_id',
'paddle_mode',
'paddle_snapshot',
'paddle_last_synced_at',
'created_by',
'updated_by',
];
protected $casts = [
'type' => CouponType::class,
'status' => CouponStatus::class,
'amount' => 'decimal:2',
'is_stackable' => 'boolean',
'enabled_for_checkout' => 'boolean',
'auto_apply' => 'boolean',
'metadata' => 'array',
'paddle_snapshot' => 'array',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'paddle_last_synced_at' => 'datetime',
];
protected static function booted(): void
{
static::saving(function (self $coupon): void {
if ($coupon->code) {
$coupon->code = Str::upper($coupon->code);
}
if ($coupon->currency) {
$coupon->currency = Str::upper($coupon->currency);
}
});
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updatedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
public function packages(): BelongsToMany
{
return $this->belongsToMany(Package::class)->withTimestamps();
}
public function checkoutSessions(): HasMany
{
return $this->hasMany(CheckoutSession::class);
}
public function redemptions(): HasMany
{
return $this->hasMany(CouponRedemption::class);
}
public function scopeActive($query)
{
return $query->where('status', CouponStatus::ACTIVE)
->where(function ($inner) {
$now = now();
$inner->whereNull('starts_at')->orWhere('starts_at', '<=', $now);
})
->where(function ($inner) {
$now = now();
$inner->whereNull('ends_at')->orWhere('ends_at', '>=', $now);
});
}
public function scopeAutoApply($query)
{
return $query->where('auto_apply', true);
}
public function remainingUsages(?int $customerUsage = null): ?int
{
$globalRemaining = $this->usage_limit !== null
? max($this->usage_limit - $this->redemptions_count, 0)
: null;
$customerRemaining = $this->per_customer_limit !== null && $customerUsage !== null
? max($this->per_customer_limit - $customerUsage, 0)
: null;
if ($globalRemaining === null) {
return $customerRemaining;
}
if ($customerRemaining === null) {
return $globalRemaining;
}
return min($globalRemaining, $customerRemaining);
}
public function isCurrentlyActive(): bool
{
if ($this->status !== CouponStatus::ACTIVE) {
return false;
}
$now = now();
if ($this->starts_at && $this->starts_at->isFuture()) {
return false;
}
if ($this->ends_at && $this->ends_at->isPast()) {
return false;
}
if ($this->usage_limit !== null && $this->redemptions_count >= $this->usage_limit) {
return false;
}
return true;
}
public function appliesToPackage(?Package $package): bool
{
if (! $package) {
return true;
}
if ($this->relationLoaded('packages')) {
return $this->packages->isEmpty() || $this->packages->contains(fn (Package $pkg) => $pkg->is($package));
}
$count = $this->packages()->count();
if ($count === 0) {
return true;
}
return $this->packages()->whereKey($package->getKey())->exists();
}
protected function code(): Attribute
{
return Attribute::make(
get: fn (?string $value) => $value ? Str::upper($value) : $value,
set: fn (?string $value) => $value ? Str::upper($value) : $value,
);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CouponRedemption extends Model
{
/** @use HasFactory<\Database\Factories\CouponRedemptionFactory> */
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'coupon_id',
'checkout_session_id',
'package_id',
'tenant_id',
'user_id',
'paddle_transaction_id',
'status',
'failure_reason',
'amount_discounted',
'currency',
'metadata',
'redeemed_at',
];
protected $casts = [
'amount_discounted' => 'decimal:2',
'metadata' => 'array',
'redeemed_at' => 'datetime',
];
public function coupon(): BelongsTo
{
return $this->belongsTo(Coupon::class);
}
public function checkoutSession(): BelongsTo
{
return $this->belongsTo(CheckoutSession::class);
}
public function package(): BelongsTo
{
return $this->belongsTo(Package::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function markSuccessful(?float $amount = null): void
{
if ($amount !== null) {
$this->amount_discounted = $amount;
}
$this->status = self::STATUS_SUCCESS;
$this->failure_reason = null;
$this->redeemed_at = now();
$this->save();
}
public function markFailed(string $reason): void
{
$this->status = self::STATUS_FAILED;
$this->failure_reason = $reason;
$this->save();
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@@ -96,6 +97,11 @@ class Package extends Model
return $this->hasMany(PackagePurchase::class); return $this->hasMany(PackagePurchase::class);
} }
public function coupons(): BelongsToMany
{
return $this->belongsToMany(Coupon::class)->withTimestamps();
}
public function isEndcustomer(): bool public function isEndcustomer(): bool
{ {
return $this->type === 'endcustomer'; return $this->type === 'endcustomer';

View File

@@ -132,6 +132,13 @@ class AppServiceProvider extends ServiceProvider
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown')); return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
}); });
RateLimiter::for('coupon-preview', function (Request $request) {
$code = strtoupper((string) $request->input('code'));
$identifier = ($request->ip() ?? 'unknown').($code ? ':'.$code : '');
return Limit::perMinute(10)->by('coupon-preview:'.$identifier);
});
Inertia::share('locale', fn () => app()->getLocale()); Inertia::share('locale', fn () => app()->getLocale());
Inertia::share('analytics', static function () { Inertia::share('analytics', static function () {
$config = config('services.matomo'); $config = config('services.matomo');

View File

@@ -2,6 +2,14 @@
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Blog\Resources\CategoryResource;
use App\Filament\Blog\Resources\PostResource;
use App\Filament\Resources\LegalPageResource;
use App\Filament\Widgets\CreditAlertsWidget;
use App\Filament\Widgets\PlatformStatsWidget;
use App\Filament\Widgets\RevenueTrendWidget;
use App\Filament\Widgets\TopTenantsByRevenue;
use App\Filament\Widgets\TopTenantsByUploads;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Http\Middleware\DispatchServingFilamentEvent;
@@ -10,7 +18,6 @@ use Filament\Panel;
use Filament\PanelProvider; use Filament\PanelProvider;
use Filament\Support\Colors\Color; use Filament\Support\Colors\Color;
use Filament\Widgets; use Filament\Widgets;
use App\Filament\Resources\LegalPageResource;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@@ -18,14 +25,6 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession; use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use App\Filament\Widgets\CreditAlertsWidget;
use App\Filament\Widgets\PlatformStatsWidget;
use App\Filament\Widgets\RevenueTrendWidget;
use App\Filament\Widgets\TopTenantsByUploads;
use App\Filament\Widgets\TopTenantsByRevenue;
use App\Filament\Blog\Resources\PostResource;
use App\Filament\Blog\Resources\CategoryResource;
use App\Filament\Blog\Resources\AuthorResource;
class SuperAdminPanelProvider extends PanelProvider class SuperAdminPanelProvider extends PanelProvider
{ {
@@ -56,6 +55,7 @@ class SuperAdminPanelProvider extends PanelProvider
CreditAlertsWidget::class, CreditAlertsWidget::class,
RevenueTrendWidget::class, RevenueTrendWidget::class,
PlatformStatsWidget::class, PlatformStatsWidget::class,
\App\Filament\Widgets\CouponUsageWidget::class,
TopTenantsByRevenue::class, TopTenantsByRevenue::class,
TopTenantsByUploads::class, TopTenantsByUploads::class,
\App\Filament\Widgets\StorageCapacityWidget::class, \App\Filament\Widgets\StorageCapacityWidget::class,
@@ -85,10 +85,9 @@ class SuperAdminPanelProvider extends PanelProvider
CategoryResource::class, CategoryResource::class,
LegalPageResource::class, LegalPageResource::class,
]) ])
->authGuard('web') ->authGuard('web');
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation // SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
// Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert // Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert
;
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Services\Checkout; namespace App\Services\Checkout;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Coupon;
use App\Models\Package; use App\Models\Package;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@@ -64,6 +65,7 @@ class CheckoutSessionService
$session->package_snapshot = $this->packageSnapshot($package); $session->package_snapshot = $this->packageSnapshot($package);
$session->amount_subtotal = Arr::get($session->package_snapshot, 'price', 0); $session->amount_subtotal = Arr::get($session->package_snapshot, 'price', 0);
$session->amount_total = Arr::get($session->package_snapshot, 'price', 0); $session->amount_total = Arr::get($session->package_snapshot, 'price', 0);
$session->amount_discount = 0;
$session->provider = CheckoutSession::PROVIDER_NONE; $session->provider = CheckoutSession::PROVIDER_NONE;
$session->status = CheckoutSession::STATUS_DRAFT; $session->status = CheckoutSession::STATUS_DRAFT;
$session->stripe_payment_intent_id = null; $session->stripe_payment_intent_id = null;
@@ -73,6 +75,10 @@ class CheckoutSessionService
$session->paddle_transaction_id = null; $session->paddle_transaction_id = null;
$session->provider_metadata = []; $session->provider_metadata = [];
$session->failure_reason = null; $session->failure_reason = null;
$session->coupon()->dissociate();
$session->coupon_code = null;
$session->coupon_snapshot = [];
$session->discount_breakdown = [];
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes); $session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
$this->appendStatus($session, CheckoutSession::STATUS_DRAFT, 'package_switched'); $this->appendStatus($session, CheckoutSession::STATUS_DRAFT, 'package_switched');
$session->save(); $session->save();
@@ -81,6 +87,31 @@ class CheckoutSessionService
}); });
} }
public function applyCoupon(CheckoutSession $session, Coupon $coupon, array $pricing): CheckoutSession
{
$snapshot = [
'coupon' => [
'id' => $coupon->id,
'code' => $coupon->code,
'type' => $coupon->type?->value,
],
'pricing' => $pricing,
];
$session->coupon()->associate($coupon);
$session->coupon_code = $coupon->code;
$session->coupon_snapshot = $snapshot;
$session->amount_subtotal = $pricing['subtotal'] ?? $session->amount_subtotal;
$session->amount_discount = $pricing['discount'] ?? 0;
$session->amount_total = $pricing['total'] ?? $session->amount_total;
$session->discount_breakdown = is_array($pricing['breakdown'] ?? null)
? $pricing['breakdown']
: [];
$session->save();
return $session->refresh();
}
public function selectProvider(CheckoutSession $session, string $provider): CheckoutSession public function selectProvider(CheckoutSession $session, string $provider): CheckoutSession
{ {
$provider = strtolower($provider); $provider = strtolower($provider);

View File

@@ -6,6 +6,7 @@ use App\Models\CheckoutSession;
use App\Models\Package; use App\Models\Package;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Services\Coupons\CouponRedemptionService;
use App\Services\Paddle\PaddleSubscriptionService; use App\Services\Paddle\PaddleSubscriptionService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -19,6 +20,7 @@ class CheckoutWebhookService
private readonly CheckoutSessionService $sessions, private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment, private readonly CheckoutAssignmentService $assignment,
private readonly PaddleSubscriptionService $paddleSubscriptions, private readonly PaddleSubscriptionService $paddleSubscriptions,
private readonly CouponRedemptionService $couponRedemptions,
) {} ) {}
public function handleStripeEvent(array $event): bool public function handleStripeEvent(array $event): bool
@@ -216,6 +218,7 @@ class CheckoutWebhookService
]); ]);
$this->sessions->markCompleted($session, now()); $this->sessions->markCompleted($session, now());
$this->couponRedemptions->recordSuccess($session, $data);
} }
return true; return true;
@@ -224,6 +227,7 @@ class CheckoutWebhookService
case 'transaction.cancelled': case 'transaction.cancelled':
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled'); $reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
$this->sessions->markFailed($session, $reason); $this->sessions->markFailed($session, $reason);
$this->couponRedemptions->recordFailure($session, $reason);
return true; return true;

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Services\Coupons;
use App\Models\CheckoutSession;
use App\Models\CouponRedemption;
use Illuminate\Support\Arr;
class CouponRedemptionService
{
public function recordSuccess(CheckoutSession $session, array $payload = []): void
{
if (! $session->coupon_id) {
return;
}
$transactionId = Arr::get($payload, 'id') ?? $session->paddle_transaction_id;
$values = [
'tenant_id' => $session->tenant_id,
'user_id' => $session->user_id,
'package_id' => $session->package_id,
'paddle_transaction_id' => $transactionId,
'status' => CouponRedemption::STATUS_SUCCESS,
'failure_reason' => null,
'amount_discounted' => $session->amount_discount,
'currency' => $session->currency ?? 'EUR',
'metadata' => array_filter([
'session_snapshot' => $session->coupon_snapshot,
'payload' => $payload,
]),
'redeemed_at' => now(),
];
CouponRedemption::query()->updateOrCreate(
[
'coupon_id' => $session->coupon_id,
'checkout_session_id' => $session->id,
],
$values,
);
$session->coupon?->increment('redemptions_count');
}
public function recordFailure(CheckoutSession $session, string $reason): void
{
if (! $session->coupon_id) {
return;
}
CouponRedemption::query()->updateOrCreate(
[
'coupon_id' => $session->coupon_id,
'checkout_session_id' => $session->id,
],
[
'tenant_id' => $session->tenant_id,
'user_id' => $session->user_id,
'package_id' => $session->package_id,
'paddle_transaction_id' => $session->paddle_transaction_id,
'status' => CouponRedemption::STATUS_FAILED,
'failure_reason' => $reason,
'amount_discounted' => $session->amount_discount,
'currency' => $session->currency ?? 'EUR',
'metadata' => array_filter([
'session_snapshot' => $session->coupon_snapshot,
]),
],
);
}
}

View File

@@ -0,0 +1,287 @@
<?php
namespace App\Services\Coupons;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\Package;
use App\Models\Tenant;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleDiscountService;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CouponService
{
public function __construct(private readonly PaddleDiscountService $paddleDiscounts) {}
/**
* @return array{coupon: Coupon, pricing: array<string, mixed>, source: string}
*/
public function preview(string $code, Package $package, ?Tenant $tenant = null): array
{
$coupon = $this->findCouponForCode($code);
$this->ensureCouponCanBeApplied($coupon, $package, $tenant);
$pricing = $this->buildPricingBreakdown($coupon, $package, $tenant);
return [
'coupon' => $coupon,
'pricing' => $pricing['pricing'],
'source' => $pricing['source'],
];
}
public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null): void
{
if (! $coupon->paddle_discount_id) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.not_synced'),
]);
}
if (! $coupon->enabled_for_checkout) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.disabled'),
]);
}
if (! $coupon->isCurrentlyActive()) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.inactive'),
]);
}
if (! $coupon->appliesToPackage($package)) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.not_applicable'),
]);
}
if ($tenant) {
$usage = $this->usageForTenant($coupon, $tenant);
$remaining = $coupon->remainingUsages($usage);
if ($remaining !== null && $remaining <= 0) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.limit_reached'),
]);
}
} elseif ($coupon->per_customer_limit !== null) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.login_required'),
]);
}
}
protected function findCouponForCode(string $code): Coupon
{
$normalized = Str::upper(trim($code));
if ($normalized === '') {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.required'),
]);
}
$coupon = Coupon::query()
->where('code', $normalized)
->first();
if (! $coupon) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.not_found'),
]);
}
if (in_array($coupon->status, [CouponStatus::PAUSED, CouponStatus::ARCHIVED, CouponStatus::DRAFT], true)) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.inactive'),
]);
}
return $coupon;
}
protected function usageForTenant(Coupon $coupon, Tenant $tenant): int
{
return $coupon->redemptions()
->where('tenant_id', $tenant->id)
->whereNot('status', CouponRedemption::STATUS_FAILED)
->count();
}
/**
* @return array{pricing: array<string, mixed>, source: string}
*/
protected function buildPricingBreakdown(Coupon $coupon, Package $package, ?Tenant $tenant = null): array
{
$currency = Str::upper($package->currency ?? 'EUR');
$subtotal = (float) $package->price;
if ($package->paddle_price_id) {
try {
$preview = $this->paddleDiscounts->previewDiscount(
$coupon,
[
[
'price_id' => $package->paddle_price_id,
'quantity' => 1,
],
],
array_filter([
'currency' => $currency,
'customer_id' => $tenant?->paddle_customer_id,
])
);
$mapped = $this->mapPaddlePreview($preview, $currency, $subtotal);
return [
'pricing' => $mapped,
'source' => 'paddle',
];
} catch (PaddleException $exception) {
Log::warning('Paddle preview failed, falling back to manual pricing', [
'coupon_id' => $coupon->id,
'package_id' => $package->id,
'message' => $exception->getMessage(),
]);
}
}
return [
'pricing' => $this->manualPricing($coupon, $currency, $subtotal),
'source' => 'manual',
];
}
protected function mapPaddlePreview(array $preview, string $currency, float $fallbackSubtotal): array
{
$totals = $this->extractTotals($preview);
$subtotal = $totals['subtotal'] ?? $fallbackSubtotal;
$discount = $totals['discount'] ?? 0.0;
$tax = $totals['tax'] ?? 0.0;
$total = $totals['total'] ?? max($subtotal - $discount + $tax, 0);
return $this->formatPricing($currency, $subtotal, $discount, $tax, $total, [
'raw' => $preview,
'breakdown' => $totals['breakdown'] ?? [],
]);
}
protected function manualPricing(Coupon $coupon, string $currency, float $subtotal): array
{
$discount = match ($coupon->type) {
CouponType::PERCENTAGE => round($subtotal * ((float) $coupon->amount) / 100, 2),
default => (float) $coupon->amount,
};
if ($coupon->type !== CouponType::PERCENTAGE && $coupon->currency && Str::upper($coupon->currency) !== $currency) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.currency_mismatch'),
]);
}
$discount = min($discount, $subtotal);
$total = max($subtotal - $discount, 0);
return $this->formatPricing($currency, $subtotal, $discount, 0, $total, [
'breakdown' => [
['type' => 'coupon', 'amount' => $discount],
],
]);
}
protected function extractTotals(array $preview): array
{
$totals = Arr::get($preview, 'totals', Arr::get($preview, 'details.totals', []));
$subtotal = $this->convertMinorAmount($totals['subtotal'] ?? ($totals['subtotal']['amount'] ?? null));
$discount = $this->convertMinorAmount($totals['discount'] ?? ($totals['discount']['amount'] ?? null));
$tax = $this->convertMinorAmount($totals['tax'] ?? ($totals['tax']['amount'] ?? null));
$total = $this->convertMinorAmount($totals['total'] ?? ($totals['total']['amount'] ?? null));
return array_filter([
'currency' => $totals['currency_code'] ?? Arr::get($preview, 'currency_code'),
'subtotal' => $subtotal,
'discount' => $discount,
'tax' => $tax,
'total' => $total,
'breakdown' => Arr::get($preview, 'discounts', []),
], static fn ($value) => $value !== null && $value !== '');
}
protected function convertMinorAmount(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_array($value) && isset($value['amount'])) {
$value = $value['amount'];
}
if (! is_numeric($value)) {
return null;
}
return round(((float) $value) / 100, 2);
}
protected function formatPricing(string $currency, float $subtotal, float $discount, float $tax, float $total, array $extra = []): array
{
$locale = $this->mapLocale(app()->getLocale());
$formatter = class_exists(\NumberFormatter::class)
? new \NumberFormatter($locale, \NumberFormatter::CURRENCY)
: null;
$format = function (float $amount, bool $allowNegative = false) use ($currency, $formatter): string {
$value = $allowNegative ? $amount : max($amount, 0);
if ($formatter) {
$formatted = $formatter->formatCurrency($value, $currency);
if ($formatted !== false) {
return $formatted;
}
}
$symbol = match ($currency) {
'EUR' => '€',
'USD' => '$',
default => $currency.' ',
};
return $symbol.number_format($value, 2, ',', '.');
};
return array_merge([
'currency' => $currency,
'subtotal' => round($subtotal, 2),
'discount' => round($discount, 2),
'tax' => round($tax, 2),
'total' => round($total, 2),
'formatted' => [
'subtotal' => $format($subtotal),
'discount' => $format(-1 * abs($discount), true),
'tax' => $format($tax),
'total' => $format($total),
],
], $extra);
}
protected function mapLocale(?string $locale): string
{
return match ($locale) {
'de', 'de_DE' => 'de_DE',
'en', 'en_GB', 'en_US' => 'en_US',
default => 'en_US',
};
}
}

View File

@@ -16,7 +16,7 @@ class PaddleCheckoutService
) {} ) {}
/** /**
* @param array{success_url?: string|null, return_url?: string|null} $options * @param array{success_url?: string|null, return_url?: string|null, discount_id?: string|null, metadata?: array} $options
*/ */
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
{ {
@@ -46,6 +46,10 @@ class PaddleCheckoutService
'cancel_url' => $returnUrl, 'cancel_url' => $returnUrl,
]; ];
if (! empty($options['discount_id'])) {
$payload['discount_id'] = $options['discount_id'];
}
if ($tenant->contact_email) { if ($tenant->contact_email) {
$payload['customer_email'] = $tenant->contact_email; $payload['customer_email'] = $tenant->contact_email;
} }

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Services\Paddle;
use App\Enums\CouponType;
use App\Models\Coupon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class PaddleDiscountService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* @return array<string, mixed>
*/
public function createDiscount(Coupon $coupon): array
{
$payload = $this->buildDiscountPayload($coupon);
$response = $this->client->post('/discounts', $payload);
return Arr::get($response, 'data', $response);
}
/**
* @return array<string, mixed>
*/
public function updateDiscount(Coupon $coupon): array
{
if (! $coupon->paddle_discount_id) {
return $this->createDiscount($coupon);
}
$payload = $this->buildDiscountPayload($coupon);
$response = $this->client->patch('/discounts/'.$coupon->paddle_discount_id, $payload);
return Arr::get($response, 'data', $response);
}
public function archiveDiscount(Coupon $coupon): void
{
if (! $coupon->paddle_discount_id) {
return;
}
$this->client->delete('/discounts/'.$coupon->paddle_discount_id);
}
/**
* @param array<int, array{price_id: string, quantity?: int}> $items
* @param array{currency?: string, address?: array{country_code: string, postal_code?: string}, customer_id?: string, address_id?: string} $context
* @return array<string, mixed>
*/
public function previewDiscount(Coupon $coupon, array $items, array $context = []): array
{
$payload = [
'items' => $items,
'discount_id' => $coupon->paddle_discount_id,
];
if (isset($context['currency'])) {
$payload['currency_code'] = Str::upper($context['currency']);
}
if (isset($context['address'])) {
$payload['address'] = $context['address'];
}
if (isset($context['customer_id'])) {
$payload['customer_id'] = $context['customer_id'];
}
if (isset($context['address_id'])) {
$payload['address_id'] = $context['address_id'];
}
$response = $this->client->post('/transactions/preview', $payload);
return Arr::get($response, 'data', $response);
}
/**
* @return array<string, mixed>
*/
protected function buildDiscountPayload(Coupon $coupon): array
{
$payload = [
'name' => $coupon->name,
'code' => $coupon->code,
'type' => $this->mapType($coupon->type),
'amount' => $this->formatAmount($coupon),
'currency_code' => $coupon->currency ?? config('app.currency', 'EUR'),
'enabled_for_checkout' => $coupon->enabled_for_checkout,
'description' => $coupon->description,
'mode' => $coupon->paddle_mode ?? 'standard',
'usage_limit' => $coupon->usage_limit,
'maximum_recurring_intervals' => null,
'recur' => false,
'restrict_to' => $this->resolveRestrictions($coupon),
'starts_at' => optional($coupon->starts_at)?->toIso8601String(),
'expires_at' => optional($coupon->ends_at)?->toIso8601String(),
];
if ($payload['type'] === 'percentage') {
unset($payload['currency_code']);
}
return Collection::make($payload)
->reject(static fn ($value) => $value === null || $value === '')
->all();
}
protected function formatAmount(Coupon $coupon): string
{
if ($coupon->type === CouponType::PERCENTAGE) {
return (string) $coupon->amount;
}
return (string) ((int) round($coupon->amount * 100));
}
protected function mapType(CouponType $type): string
{
return match ($type) {
CouponType::PERCENTAGE => 'percentage',
CouponType::FLAT => 'flat',
CouponType::FLAT_PER_SEAT => 'flat_per_seat',
};
}
protected function resolveRestrictions(Coupon $coupon): ?array
{
$packages = ($coupon->relationLoaded('packages')
? $coupon->packages
: $coupon->packages()->get())
->whereNotNull('paddle_price_id');
if ($packages->isEmpty()) {
return null;
}
$prices = $packages->pluck('paddle_price_id')->filter()->values();
return $prices->isEmpty() ? null : $prices->all();
}
}

View File

@@ -28,17 +28,20 @@ class JoinTokenLayoutRegistry
'accent' => '#B85C76', 'accent' => '#B85C76',
'secondary' => '#E7D6DC', 'secondary' => '#E7D6DC',
'badge' => '#7A9375', 'badge' => '#7A9375',
'badge_label' => 'Unsere Gästegalerie', 'badge_label' => 'Digitale Gästebox',
'instructions_heading' => 'So seid ihr dabei', 'instructions_heading' => 'So läuft\'s für eure Gäste',
'link_heading' => 'Falls der Scan nicht klappt', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
'cta_label' => 'Gästegalerie öffnen', 'link_label' => 'fotospiel.app/DEINCODE',
'cta_caption' => 'Jetzt Erinnerungen sammeln', 'cta_label' => 'Fotos & Grüße teilen',
'cta_caption' => 'Sofort starten',
'qr' => ['size_px' => 640], 'qr' => ['size_px' => 640],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'QR-Code scannen und mit eurem Lieblingsnamen anmelden.', 'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Ein paar Schnappschüsse teilen gern auch Behind-the-Scenes!', 'Anzeigenamen wählen kein Account nötig.',
'Likes vergeben und Grüße für das Brautpaar schreiben.', 'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
], ],
], ],
'midnight-gala' => [ 'midnight-gala' => [
@@ -57,17 +60,20 @@ class JoinTokenLayoutRegistry
'accent' => '#F9C74F', 'accent' => '#F9C74F',
'secondary' => '#4E5D8F', 'secondary' => '#4E5D8F',
'badge' => '#F94144', 'badge' => '#F94144',
'badge_label' => 'Team Lounge Access', 'badge_label' => 'Digitale Gästebox',
'instructions_heading' => 'In drei Schritten bereit', 'instructions_heading' => 'So läuft\'s für eure Gäste',
'link_heading' => 'Link teilen statt scannen', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
'cta_label' => 'Jetzt Event-Hub öffnen', 'link_label' => 'fotospiel.app/DEINCODE',
'cta_caption' => 'Programm, Uploads & Highlights', 'cta_label' => 'Scan & losknipsen',
'cta_caption' => 'Keine App nötig',
'qr' => ['size_px' => 640], 'qr' => ['size_px' => 640],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'QR-Code scannen oder Kurzlink eingeben.', 'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Mit Firmen-E-Mail anmelden und Zugang bestätigen.', 'Anzeigenamen wählen kein Account nötig.',
'Agenda verfolgen, Fotos teilen und Highlights voten.', 'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
], ],
], ],
'garden-brunch' => [ 'garden-brunch' => [
@@ -86,17 +92,20 @@ class JoinTokenLayoutRegistry
'accent' => '#6BAA75', 'accent' => '#6BAA75',
'secondary' => '#DDE9D8', 'secondary' => '#DDE9D8',
'badge' => '#F1C376', 'badge' => '#F1C376',
'badge_label' => 'Brunch Fotostation', 'badge_label' => 'Digitale Gästebox',
'instructions_heading' => 'So funktionierts', 'instructions_heading' => 'So läuft\'s für eure Gäste',
'link_heading' => 'Alternativ zum Scannen', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
'cta_label' => 'Gästebuch öffnen', 'link_label' => 'fotospiel.app/DEINCODE',
'cta_caption' => 'Eure Grüße festhalten', 'cta_label' => 'Jetzt Erinnerungen hochladen',
'cta_caption' => 'Los gehts',
'qr' => ['size_px' => 660], 'qr' => ['size_px' => 660],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'QR-Code scannen und Namen eintragen.', 'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Lieblingsfoto hochladen oder neue Momente festhalten.', 'Anzeigenamen wählen kein Account nötig.',
'Aufgaben ausprobieren und anderen ein Herz dalassen.', 'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
], ],
], ],
'sparkler-soiree' => [ 'sparkler-soiree' => [
@@ -115,17 +124,20 @@ class JoinTokenLayoutRegistry
'accent' => '#F9A826', 'accent' => '#F9A826',
'secondary' => '#DDB7FF', 'secondary' => '#DDB7FF',
'badge' => '#FF6F61', 'badge' => '#FF6F61',
'badge_label' => 'Night Shots', 'badge_label' => 'Digitale Gästebox',
'instructions_heading' => 'Step-by-Step', 'instructions_heading' => 'So läuft\'s für eure Gäste',
'link_heading' => 'QR funktioniert nicht?', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
'cta_label' => 'Partyfeed starten', 'link_label' => 'fotospiel.app/DEINCODE',
'cta_caption' => 'Momente live teilen', 'cta_label' => 'Galerie öffnen',
'cta_caption' => 'Challenges spielen',
'qr' => ['size_px' => 680], 'qr' => ['size_px' => 680],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'Code scannen und kurz registrieren.', 'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Spotlights & Challenges entdecken.', 'Anzeigenamen wählen kein Account nötig.',
'Fotos hochladen und die besten Shots voten.', 'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
], ],
], ],
'confetti-bash' => [ 'confetti-bash' => [
@@ -144,17 +156,20 @@ class JoinTokenLayoutRegistry
'accent' => '#FF6F61', 'accent' => '#FF6F61',
'secondary' => '#F9D6A5', 'secondary' => '#F9D6A5',
'badge' => '#4E88FF', 'badge' => '#4E88FF',
'badge_label' => 'Party-Schnappschüsse', 'badge_label' => 'Digitale Gästebox',
'instructions_heading' => 'Leg direkt los', 'instructions_heading' => 'So läuft\'s für eure Gäste',
'link_heading' => 'Kurzlink für Gäste', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
'cta_label' => 'Zur Geburtstagswand', 'link_label' => 'fotospiel.app/DEINCODE',
'cta_caption' => 'Fotos & Grüße posten', 'cta_label' => 'Uploads beginnen',
'cta_caption' => 'Likes vergeben',
'qr' => ['size_px' => 680], 'qr' => ['size_px' => 680],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => [
'QR-Code scannen und Wunschname auswählen.', 'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Dein erstes Foto oder Video hochladen.', 'Anzeigenamen wählen kein Account nötig.',
'Freunde einladen, Likes vergeben und gemeinsam feiern!', 'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
], ],
], ],
]; ];

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Support;
use App\Models\Tenant;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TenantRequestResolver
{
public static function resolve(Request $request): Tenant
{
$tenant = $request->attributes->get('tenant');
if ($tenant instanceof Tenant) {
return $tenant;
}
$tenantId = $request->attributes->get('tenant_id')
?? $request->attributes->get('current_tenant_id')
?? $request->user()?->tenant_id;
if ($tenantId) {
$tenant = Tenant::query()->find($tenantId);
if ($tenant) {
$request->attributes->set('tenant', $tenant);
return $tenant;
}
}
throw new HttpResponseException(ApiError::response(
'tenant_context_missing',
'Tenant context missing',
'Unable to determine tenant for the current request.',
Response::HTTP_UNAUTHORIZED
));
}
}

View File

@@ -20,6 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
) )
->withCommands([ ->withCommands([
\App\Console\Commands\CheckEventPackages::class, \App\Console\Commands\CheckEventPackages::class,
\App\Console\Commands\ExportCouponRedemptions::class,
]) ])
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
$schedule->command('package:check-status')->dailyAt('06:00'); $schedule->command('package:check-status')->dailyAt('06:00');

View File

@@ -0,0 +1,51 @@
<?php
namespace Database\Factories;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Coupon>
*/
class CouponFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$type = $this->faker->randomElement([
CouponType::PERCENTAGE,
CouponType::FLAT,
]);
$amount = $type === CouponType::PERCENTAGE
? $this->faker->numberBetween(5, 40)
: $this->faker->numberBetween(5, 150);
return [
'name' => $this->faker->words(3, true),
'code' => Str::upper(Str::random(8)),
'type' => $type,
'amount' => $amount,
'currency' => $type === CouponType::PERCENTAGE ? null : 'EUR',
'status' => CouponStatus::ACTIVE,
'is_stackable' => false,
'enabled_for_checkout' => true,
'auto_apply' => false,
'usage_limit' => 100,
'per_customer_limit' => 1,
'description' => $this->faker->sentence(),
'metadata' => ['note' => 'factory'],
'starts_at' => now()->subDay(),
'ends_at' => now()->addMonth(),
'paddle_discount_id' => 'dsc_'.Str::upper(Str::random(10)),
'paddle_mode' => 'standard',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CouponRedemption>
*/
class CouponRedemptionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'coupon_id' => Coupon::factory(),
'package_id' => Package::factory(),
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'status' => CouponRedemption::STATUS_SUCCESS,
'amount_discounted' => $this->faker->randomFloat(2, 5, 150),
'currency' => 'EUR',
'redeemed_at' => now(),
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('coupons', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code')->unique();
$table->string('type', 40);
$table->decimal('amount', 10, 2)->default(0);
$table->char('currency', 3)->nullable();
$table->string('status', 40)->default('draft');
$table->boolean('is_stackable')->default(false);
$table->boolean('enabled_for_checkout')->default(true);
$table->boolean('auto_apply')->default(false);
$table->unsignedInteger('usage_limit')->nullable();
$table->unsignedInteger('per_customer_limit')->nullable();
$table->unsignedInteger('redemptions_count')->default(0);
$table->text('description')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->string('paddle_discount_id')->nullable()->unique();
$table->string('paddle_mode', 40)->default('standard');
$table->json('paddle_snapshot')->nullable();
$table->timestamp('paddle_last_synced_at')->nullable();
$table->foreignIdFor(\App\Models\User::class, 'created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignIdFor(\App\Models\User::class, 'updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'starts_at', 'ends_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('coupons');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('coupon_package', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(\App\Models\Coupon::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(\App\Models\Package::class)->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['coupon_id', 'package_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('coupon_package');
}
};

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('coupon_redemptions', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(\App\Models\Coupon::class)->constrained()->cascadeOnDelete();
$table->foreignUuid('checkout_session_id')->nullable()->constrained('checkout_sessions')->nullOnDelete();
$table->foreignIdFor(\App\Models\Package::class)->nullable()->constrained()->nullOnDelete();
$table->foreignIdFor(\App\Models\Tenant::class)->nullable()->constrained()->nullOnDelete();
$table->foreignIdFor(\App\Models\User::class)->nullable()->constrained()->nullOnDelete();
$table->string('paddle_transaction_id')->nullable();
$table->string('status', 40)->default('pending');
$table->text('failure_reason')->nullable();
$table->decimal('amount_discounted', 10, 2)->default(0);
$table->char('currency', 3)->default('EUR');
$table->json('metadata')->nullable();
$table->timestamp('redeemed_at')->nullable();
$table->timestamps();
$table->index(['status', 'redeemed_at']);
$table->unique('paddle_transaction_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('coupon_redemptions');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('checkout_sessions', function (Blueprint $table) {
$table->foreignIdFor(\App\Models\Coupon::class)->nullable()->after('package_snapshot')->constrained()->nullOnDelete();
$table->string('coupon_code')->nullable()->after('coupon_id');
$table->json('coupon_snapshot')->nullable()->after('coupon_code');
$table->decimal('amount_discount', 10, 2)->default(0)->after('amount_total');
$table->json('discount_breakdown')->nullable()->after('amount_discount');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('checkout_sessions', function (Blueprint $table) {
$table->dropConstrainedForeignId('coupon_id');
$table->dropColumn(['coupon_code', 'coupon_snapshot', 'amount_discount', 'discount_breakdown']);
});
}
};

View File

@@ -0,0 +1,12 @@
# Coupon Ops Enhancements (2025-11-08)
## Summary
- Added `CouponRedemptionService` + Paddle webhook hooks so successful/failed redemptions are logged and counted.
- Exposed `/api/v1/marketing/coupons/preview` with per-IP rate limiting, structured logging, and localized responses.
- Marketing funnel + checkout wizard auto-prefill coupons from `?coupon=` links and persist selections through Paddle checkout.
- Super Admin dashboard now shows a "Coupon performance (30d)" widget with recent usage + discount totals.
- New artisan command `php artisan coupons:export` exports the last N days of redemptions to CSV for finance/reporting.
## Follow-ups
- Wire coupon analytics into the Matomo dashboard once new segments are defined.
- Expand fraud tooling with IP/device reputation once we roll out the affiliate program.

View File

@@ -246,6 +246,8 @@ export type TenantTask = {
difficulty: 'easy' | 'medium' | 'hard' | null; difficulty: 'easy' | 'medium' | 'hard' | null;
due_date: string | null; due_date: string | null;
is_completed: boolean; is_completed: boolean;
event_type_id: number | null;
event_type?: TenantEventType | null;
tenant_id: number | null; tenant_id: number | null;
collection_id: number | null; collection_id: number | null;
source_task_id: number | null; source_task_id: number | null;
@@ -693,6 +695,11 @@ function normalizeTask(task: JsonValue): TenantTask {
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {}); const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {}); const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
const exampleTranslations = normalizeTranslationMap(task.example_text ?? {}); const exampleTranslations = normalizeTranslationMap(task.example_text ?? {});
const eventType = normalizeEventType(task.event_type ?? task.eventType ?? null);
const eventTypeId =
typeof task.event_type_id === 'number'
? Number(task.event_type_id)
: eventType?.id ?? null;
return { return {
id: Number(task.id ?? 0), id: Number(task.id ?? 0),
@@ -709,6 +716,8 @@ function normalizeTask(task: JsonValue): TenantTask {
difficulty: (task.difficulty ?? null) as TenantTask['difficulty'], difficulty: (task.difficulty ?? null) as TenantTask['difficulty'],
due_date: task.due_date ?? null, due_date: task.due_date ?? null,
is_completed: Boolean(task.is_completed ?? false), is_completed: Boolean(task.is_completed ?? false),
event_type_id: eventTypeId,
event_type: eventType,
tenant_id: task.tenant_id ?? null, tenant_id: task.tenant_id ?? null,
collection_id: task.collection_id ?? null, collection_id: task.collection_id ?? null,
source_task_id: task.source_task_id ?? null, source_task_id: task.source_task_id ?? null,

View File

@@ -1,33 +1,33 @@
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { LayoutDashboard, CalendarDays, Camera, Settings } from 'lucide-react';
LayoutDashboard,
CalendarDays,
Sparkles,
CreditCard,
Settings as SettingsIcon,
} from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
ADMIN_HOME_PATH, ADMIN_HOME_PATH,
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_SETTINGS_PATH, ADMIN_SETTINGS_PATH,
ADMIN_BILLING_PATH, ADMIN_BILLING_PATH,
ADMIN_ENGAGEMENT_PATH,
} from '../constants'; } from '../constants';
import { LanguageSwitcher } from './LanguageSwitcher';
import { registerApiErrorListener } from '../lib/apiError'; import { registerApiErrorListener } from '../lib/apiError';
import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api'; import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api';
import { NotificationCenter } from './NotificationCenter';
import { UserMenu } from './UserMenu';
import { useEventContext } from '../context/EventContext';
import { EventSwitcher, EventMenuBar } from './EventNav';
const navItems = [ type NavItem = {
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', icon: LayoutDashboard, end: true }, key: string;
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events', icon: CalendarDays }, to: string;
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement', icon: Sparkles }, label: string;
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing', icon: CreditCard }, icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings', icon: SettingsIcon }, end?: boolean;
]; highlight?: boolean;
prefetchKey?: string;
};
interface AdminLayoutProps { interface AdminLayoutProps {
title: string; title: string;
@@ -39,6 +39,51 @@ interface AdminLayoutProps {
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) { export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const prefetchedPathsRef = React.useRef<Set<string>>(new Set()); const prefetchedPathsRef = React.useRef<Set<string>>(new Set());
const { events } = useEventContext();
const singleEvent = events.length === 1 ? events[0] : null;
const eventsPath = singleEvent?.slug ? ADMIN_EVENT_VIEW_PATH(singleEvent.slug) : ADMIN_EVENTS_PATH;
const eventsLabel = events.length === 1
? t('navigation.event', { defaultValue: 'Event' })
: t('navigation.events');
const photosPath = singleEvent?.slug ? ADMIN_EVENT_PHOTOS_PATH(singleEvent.slug) : ADMIN_EVENTS_PATH;
const photosLabel = t('navigation.photos', { defaultValue: 'Fotos' });
const settingsLabel = t('navigation.settings');
const navItems = React.useMemo<NavItem[]>(() => [
{
key: 'dashboard',
to: ADMIN_HOME_PATH,
label: t('navigation.dashboard'),
icon: LayoutDashboard,
end: true,
prefetchKey: ADMIN_HOME_PATH,
},
{
key: 'events',
to: eventsPath,
label: eventsLabel,
icon: CalendarDays,
end: Boolean(singleEvent?.slug),
highlight: events.length === 1,
prefetchKey: ADMIN_EVENTS_PATH,
},
{
key: 'photos',
to: photosPath,
label: photosLabel,
icon: Camera,
end: Boolean(singleEvent?.slug),
prefetchKey: singleEvent?.slug ? undefined : ADMIN_EVENTS_PATH,
},
{
key: 'settings',
to: ADMIN_SETTINGS_PATH,
label: settingsLabel,
icon: Settings,
prefetchKey: ADMIN_SETTINGS_PATH,
},
], [eventsLabel, eventsPath, photosPath, photosLabel, settingsLabel, singleEvent, events.length, t]);
const prefetchers = React.useMemo(() => ({ const prefetchers = React.useMemo(() => ({
[ADMIN_HOME_PATH]: () => [ADMIN_HOME_PATH]: () =>
@@ -48,7 +93,6 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
getTenantPackagesOverview(), getTenantPackagesOverview(),
]).then(() => undefined), ]).then(() => undefined),
[ADMIN_EVENTS_PATH]: () => getEvents().then(() => undefined), [ADMIN_EVENTS_PATH]: () => getEvents().then(() => undefined),
[ADMIN_ENGAGEMENT_PATH]: () => getEvents().then(() => undefined),
[ADMIN_BILLING_PATH]: () => getTenantPackagesOverview().then(() => undefined), [ADMIN_BILLING_PATH]: () => getTenantPackagesOverview().then(() => undefined),
[ADMIN_SETTINGS_PATH]: () => Promise.resolve(), [ADMIN_SETTINGS_PATH]: () => Promise.resolve(),
}), []); }), []);
@@ -109,35 +153,43 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<EventSwitcher />
{actions} {actions}
<LanguageSwitcher /> <NotificationCenter />
<UserMenu />
</div> </div>
</div> </div>
<nav className="hidden border-t border-slate-200/60 dark:border-white/5 sm:block"> <nav className="hidden border-t border-slate-200/60 dark:border-white/5 sm:block">
<div className="mx-auto flex w-full max-w-6xl items-center gap-1 px-4 py-2 sm:px-6"> <div className="mx-auto flex w-full max-w-6xl items-center gap-1 px-4 py-2 sm:px-6">
{navItems.map(({ to, labelKey, icon: Icon, end }) => ( {navItems.map((item) => (
<NavLink <NavLink
key={to} key={item.key}
to={to} to={item.to}
end={end} end={item.end}
onPointerEnter={() => triggerPrefetch(to)} onPointerEnter={() => triggerPrefetch(item.prefetchKey ?? item.to)}
onFocus={() => triggerPrefetch(to)} onFocus={() => triggerPrefetch(item.prefetchKey ?? item.to)}
onTouchStart={() => triggerPrefetch(to)} onTouchStart={() => triggerPrefetch(item.prefetchKey ?? item.to)}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex items-center gap-2 rounded-2xl px-3 py-2 text-xs font-semibold uppercase tracking-wide transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60', 'flex items-center gap-2 rounded-2xl px-3 py-2 text-xs font-semibold uppercase tracking-wide transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
isActive isActive
? 'bg-rose-600 text-white shadow shadow-rose-300/40' ? 'bg-rose-600 text-white shadow shadow-rose-300/40'
: 'text-slate-500 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white' : cn(
item.highlight
? 'text-rose-600 dark:text-rose-200'
: 'text-slate-500 dark:text-slate-300',
'hover:text-slate-900 dark:hover:text-white'
)
) )
} }
> >
<Icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
{t(labelKey)} {item.label}
</NavLink> </NavLink>
))} ))}
</div> </div>
</nav> </nav>
<EventMenuBar />
</header> </header>
<main className="relative z-10 mx-auto w-full max-w-5xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+5.5rem)] pt-5 sm:px-6 md:pb-16"> <main className="relative z-10 mx-auto w-full max-w-5xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+5.5rem)] pt-5 sm:px-6 md:pb-16">
@@ -154,7 +206,7 @@ function TenantMobileNav({
items, items,
onPrefetch, onPrefetch,
}: { }: {
items: typeof navItems; items: NavItem[];
onPrefetch: (path: string) => void; onPrefetch: (path: string) => void;
}) { }) {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
@@ -167,25 +219,30 @@ function TenantMobileNav({
/> />
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200/80 bg-white/95 px-4 pb-[calc(env(safe-area-inset-bottom,0)+0.75rem)] pt-3 shadow-2xl shadow-rose-300/15 backdrop-blur supports-[backdrop-filter]:bg-white/90 dark:border-slate-800/70 dark:bg-slate-950/90"> <div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200/80 bg-white/95 px-4 pb-[calc(env(safe-area-inset-bottom,0)+0.75rem)] pt-3 shadow-2xl shadow-rose-300/15 backdrop-blur supports-[backdrop-filter]:bg-white/90 dark:border-slate-800/70 dark:bg-slate-950/90">
<div className="mx-auto flex max-w-xl items-center justify-around gap-1"> <div className="mx-auto flex max-w-xl items-center justify-around gap-1">
{items.map(({ to, labelKey, icon: Icon, end }) => ( {items.map((item) => (
<NavLink <NavLink
key={to} key={item.key}
to={to} to={item.to}
end={end} end={item.end}
onPointerEnter={() => onPrefetch(to)} onPointerEnter={() => onPrefetch(item.prefetchKey ?? item.to)}
onFocus={() => onPrefetch(to)} onFocus={() => onPrefetch(item.prefetchKey ?? item.to)}
onTouchStart={() => onPrefetch(to)} onTouchStart={() => onPrefetch(item.prefetchKey ?? item.to)}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-xs font-semibold text-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:focus-visible:ring-offset-slate-950', 'flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-xs font-semibold text-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:focus-visible:ring-offset-slate-950',
isActive isActive
? 'bg-rose-600 text-white shadow-md shadow-rose-400/25' ? 'bg-rose-600 text-white shadow-md shadow-rose-400/25'
: 'hover:text-rose-700 dark:hover:text-rose-200' : cn(
item.highlight
? 'text-rose-600 dark:text-rose-200'
: 'text-slate-600 dark:text-slate-300',
'hover:text-rose-700 dark:hover:text-rose-200'
)
) )
} }
> >
<Icon className="h-5 w-5" /> <item.icon className="h-5 w-5" />
<span>{t(labelKey)}</span> <span>{item.label}</span>
</NavLink> </NavLink>
))} ))}
</div> </div>

View File

@@ -0,0 +1,225 @@
import React from 'react';
import { useNavigate, NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, ChevronDown, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { useEventContext } from '../context/EventContext';
import {
ADMIN_EVENT_CREATE_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_VIEW_PATH,
} from '../constants';
import type { TenantEvent } from '../api';
import { cn } from '@/lib/utils';
function resolveEventName(event: TenantEvent): string {
const name = event.name;
if (typeof name === 'string' && name.trim().length > 0) {
return name;
}
if (name && typeof name === 'object') {
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
if (first) {
return first;
}
}
return event.slug ?? 'Event';
}
function formatEventDate(value?: string | null, locale = 'de-DE'): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
} catch {
return date.toISOString().slice(0, 10);
}
}
function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']) {
return [
{ key: 'summary', label: t('eventMenu.summary', 'Übersicht'), href: ADMIN_EVENT_VIEW_PATH(slug) },
{ key: 'photos', label: t('eventMenu.photos', 'Uploads'), href: ADMIN_EVENT_PHOTOS_PATH(slug) },
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
{ key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'toolkit', label: t('eventMenu.toolkit', 'Toolkit'), href: ADMIN_EVENT_TOOLKIT_PATH(slug) },
];
}
export function EventSwitcher() {
const { events, activeEvent, selectEvent } = useEventContext();
const { t, i18n } = useTranslation('common');
const navigate = useNavigate();
const [open, setOpen] = React.useState(false);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const buttonLabel = activeEvent ? resolveEventName(activeEvent) : t('eventSwitcher.placeholder', 'Event auswählen');
const buttonHint = activeEvent?.event_date
? formatEventDate(activeEvent.event_date, locale)
: events.length > 1
? t('eventSwitcher.multiple', 'Mehrere Events')
: t('eventSwitcher.empty', 'Noch kein Event');
const handleSelect = (event: TenantEvent) => {
selectEvent(event.slug ?? null);
setOpen(false);
if (event.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(event.slug));
}
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="rounded-full border-rose-100 bg-white/80 px-4 text-sm font-semibold text-slate-700 shadow-sm hover:bg-rose-50 dark:border-white/20 dark:bg-white/10 dark:text-white">
<CalendarDays className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">{buttonLabel}</span>
<span className="text-xs text-slate-500 dark:text-slate-300 sm:ml-2">
{buttonHint}
</span>
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="rounded-t-3xl p-0">
<SheetHeader className="border-b border-slate-200 p-4 dark:border-white/10">
<SheetTitle>{t('eventSwitcher.title', 'Event auswählen')}</SheetTitle>
<SheetDescription>
{events.length === 0
? t('eventSwitcher.emptyDescription', 'Erstelle dein erstes Event, um loszulegen.')
: t('eventSwitcher.description', 'Wähle ein Event für die Bearbeitung oder lege ein neues an.')}
</SheetDescription>
</SheetHeader>
<div className="max-h-[60vh] overflow-y-auto p-4">
{events.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-600 dark:border-white/15 dark:text-slate-300">
{t('eventSwitcher.noEvents', 'Noch keine Events vorhanden.')}
</div>
) : (
<div className="space-y-2">
{events.map((event) => {
const isActive = activeEvent?.id === event.id;
const date = formatEventDate(event.event_date, locale);
return (
<button
key={event.id}
type="button"
onClick={() => handleSelect(event)}
className={cn(
'w-full rounded-2xl border px-4 py-3 text-left transition hover:border-rose-200 dark:border-white/10 dark:bg-white/5',
isActive
? 'border-rose-500 bg-rose-50/70 text-rose-900 dark:border-rose-300 dark:bg-rose-200/10 dark:text-rose-100'
: 'bg-white text-slate-900'
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">{resolveEventName(event)}</p>
<p className="text-xs text-slate-500 dark:text-slate-300">{date ?? t('eventSwitcher.noDate', 'Kein Datum')}</p>
</div>
{isActive ? (
<Badge className="bg-rose-600 text-white">{t('eventSwitcher.active', 'Aktiv')}</Badge>
) : null}
</div>
</button>
);
})}
</div>
)}
<Button
size="sm"
variant="secondary"
className="mt-4 w-full rounded-full"
onClick={() => {
setOpen(false);
navigate(ADMIN_EVENT_CREATE_PATH);
}}
>
<PlusCircle className="mr-2 h-4 w-4" />
{t('eventSwitcher.create', 'Neues Event anlegen')}
</Button>
{activeEvent?.slug ? (
<div className="mt-6 space-y-3">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-300">
{t('eventSwitcher.actions', 'Event-Funktionen')}
</p>
<div className="grid gap-2">
{buildEventLinks(activeEvent.slug, t).map((action) => (
<Button
key={action.key}
variant="ghost"
className="justify-between rounded-2xl border border-slate-200 bg-white text-left text-sm font-semibold text-slate-700 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:text-white"
onClick={() => {
setOpen(false);
navigate(action.href);
}}
>
{action.label}
<ChevronDown className="rotate-[-90deg]" />
</Button>
))}
</div>
</div>
) : null}
</div>
</SheetContent>
</Sheet>
);
}
export function EventMenuBar() {
const { activeEvent } = useEventContext();
const { t } = useTranslation('common');
const location = useLocation();
if (!activeEvent?.slug) {
return null;
}
const links = buildEventLinks(activeEvent.slug, t);
return (
<div className="border-t border-slate-200 bg-white/80 px-4 py-2 dark:border-white/10 dark:bg-slate-950/80">
<div className="flex items-center gap-2 overflow-x-auto text-sm">
{links.map((link) => (
<NavLink
key={link.key}
to={link.href}
className={({ isActive }) =>
cn(
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold transition',
isActive || location.pathname.startsWith(link.href)
? 'bg-rose-600 text-white shadow shadow-rose-400/40'
: 'bg-white text-slate-600 ring-1 ring-slate-200 hover:text-rose-600 dark:bg-white/10 dark:text-white dark:ring-white/10'
)
}
>
{link.label}
</NavLink>
))}
</div>
</div>
);
}

View File

@@ -10,42 +10,13 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import i18n from '../i18n'; import { SUPPORTED_LANGUAGES, getCurrentLocale, switchLocale, type SupportedLocale } from '../lib/locale';
type SupportedLocale = 'de' | 'en';
const SUPPORTED_LANGUAGES: Array<{ code: SupportedLocale; labelKey: string }> = [
{ code: 'de', labelKey: 'language.de' },
{ code: 'en', labelKey: 'language.en' },
];
function getCsrfToken(): string {
return document.querySelector<HTMLMetaElement>('meta[name=\"csrf-token\"]')?.content ?? '';
}
async function persistLocale(locale: SupportedLocale): Promise<void> {
const response = await fetch('/set-locale', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ locale }),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`locale update failed with status ${response.status}`);
}
}
export function LanguageSwitcher() { export function LanguageSwitcher() {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null); const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = (i18n.language || document.documentElement.lang || 'de') as SupportedLocale; const currentLocale = getCurrentLocale();
const changeLanguage = React.useCallback( const changeLanguage = React.useCallback(
async (locale: SupportedLocale) => { async (locale: SupportedLocale) => {
@@ -55,12 +26,9 @@ export function LanguageSwitcher() {
setPendingLocale(locale); setPendingLocale(locale);
try { try {
await persistLocale(locale); await switchLocale(locale);
await i18n.changeLanguage(locale);
document.documentElement.setAttribute('lang', locale);
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error('Failed to switch language', error); console.error('Failed to switch language', error);
} }
} finally { } finally {

View File

@@ -0,0 +1,306 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, Bell, CheckCircle2, Clock, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { getDashboardSummary, getEvents, type DashboardSummary, type TenantEvent } from '../api';
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
import { isAuthError } from '../auth/tokens';
export type NotificationTone = 'info' | 'warning' | 'success';
interface TenantNotification {
id: string;
title: string;
description?: string;
tone: NotificationTone;
action?: {
label: string;
onSelect: () => void;
};
}
export function NotificationCenter() {
const navigate = useNavigate();
const { t } = useTranslation('dashboard');
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [notifications, setNotifications] = React.useState<TenantNotification[]>([]);
const [dismissed, setDismissed] = React.useState<Set<string>>(new Set());
const visibleNotifications = React.useMemo(
() => notifications.filter((notification) => !dismissed.has(notification.id)),
[notifications, dismissed]
);
const unreadCount = visibleNotifications.length;
const refresh = React.useCallback(async () => {
setLoading(true);
try {
const [events, summary] = await Promise.all([
getEvents().catch(() => [] as TenantEvent[]),
getDashboardSummary().catch(() => null as DashboardSummary | null),
]);
setNotifications(buildNotifications({
events,
summary,
navigate,
t,
}));
} catch (error) {
if (!isAuthError(error)) {
console.error('[NotificationCenter] Failed to load data', error);
}
} finally {
setLoading(false);
}
}, [navigate, t]);
React.useEffect(() => {
refresh();
}, [refresh]);
const handleDismiss = React.useCallback((id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
}, []);
const iconForTone: Record<NotificationTone, React.ReactNode> = React.useMemo(
() => ({
info: <Clock className="h-4 w-4 text-slate-400" />,
warning: <AlertTriangle className="h-4 w-4 text-amber-500" />,
success: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
}),
[]
);
return (
<DropdownMenu open={open} onOpenChange={(next) => {
setOpen(next);
if (next) {
refresh();
}
}}>
<DropdownMenuTrigger asChild>
<Button
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' })}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<Badge className="absolute -right-1 -top-1 rounded-full bg-rose-600 px-1.5 text-[10px] font-semibold text-white">
{unreadCount}
</Badge>
) : null}
</Button>
</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>
{!loading && unreadCount === 0 ? (
<Badge variant="outline">{t('notifications.empty', { defaultValue: 'Aktuell ruhig' })}</Badge>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{loading ? (
<div className="space-y-2 p-3">
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-12 w-full rounded-xl" />
</div>
) : (
<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.' })}
</p>
) : (
visibleNotifications.map((item) => (
<DropdownMenuItem key={item.id} className="flex flex-col gap-1 py-3" onSelect={(event) => event.preventDefault()}>
<div className="flex items-start gap-3">
<span className="mt-0.5">{iconForTone[item.tone]}</span>
<div className="flex-1 space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{item.title}</p>
{item.description ? (
<p className="text-xs text-slate-600 dark:text-slate-300">{item.description}</p>
) : null}
<div className="flex flex-wrap gap-2 pt-1">
{item.action ? (
<Button
size="sm"
variant="outline"
className="h-7 rounded-full px-3 text-xs"
onClick={() => {
item.action?.onSelect();
setOpen(false);
}}
>
{item.action.label}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
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' })}
</Button>
</div>
</div>
</div>
</DropdownMenuItem>
))
)}
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(event) => {
event.preventDefault();
setDismissed(new Set());
refresh();
}}
>
<Plus className="h-3.5 w-3.5" />
{t('notifications.action.refresh', { defaultValue: 'Neue Hinweise laden' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function buildNotifications({
events,
summary,
navigate,
t,
}: {
events: TenantEvent[];
summary: DashboardSummary | null;
navigate: ReturnType<typeof useNavigate>;
t: (key: string, options?: Record<string, unknown>) => string;
}): TenantNotification[] {
const items: TenantNotification[] = [];
const primary = events[0] ?? null;
const now = Date.now();
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.',
}),
tone: 'warning',
action: {
label: t('notifications.noEvents.cta', { defaultValue: 'Event erstellen' }),
onSelect: () => navigate(ADMIN_EVENT_CREATE_PATH),
},
});
return items;
}
events.forEach((event) => {
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.',
}),
tone: 'info',
action: event.slug
? {
label: t('notifications.draftEvent.cta', { defaultValue: 'Event öffnen' }),
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
}
: undefined,
});
}
const eventDate = event.event_date ? new Date(event.event_date).getTime() : null;
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,
});
}
}
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.`,
}),
tone: 'warning',
action: event.slug
? {
label: t('notifications.pendingUploads.cta', { defaultValue: 'Uploads öffnen' }),
onSelect: () => navigate(`${ADMIN_EVENT_VIEW_PATH(event.slug!)}#photos`),
}
: undefined,
});
}
});
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.`,
}),
tone: 'success',
action: primary?.slug
? {
label: t('notifications.newPhotos.cta', { defaultValue: 'Galerie öffnen' }),
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(primary.slug!)),
}
: {
label: t('notifications.newPhotos.ctaFallback', { defaultValue: 'Events ansehen' }),
onSelect: () => navigate(ADMIN_EVENTS_PATH),
},
});
}
return items;
}

View File

@@ -0,0 +1,161 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { HelpCircle, LogOut, Monitor, Moon, Settings, Sun, User, Languages, CreditCard } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useAuth } from '../auth/context';
import { ADMIN_FAQ_PATH, ADMIN_PROFILE_PATH, ADMIN_SETTINGS_PATH, ADMIN_BILLING_PATH } from '../constants';
import { useAppearance } from '@/hooks/use-appearance';
import { SUPPORTED_LANGUAGES, getCurrentLocale, switchLocale, type SupportedLocale } from '../lib/locale';
export function UserMenu() {
const { user, logout } = useAuth();
const { appearance, updateAppearance } = useAppearance();
const navigate = useNavigate();
const { t } = useTranslation('common');
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = getCurrentLocale();
const initials = React.useMemo(() => {
if (user?.name) {
return user.name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}
if (user?.email) {
return user.email.charAt(0).toUpperCase();
}
return 'TU';
}, [user?.name, user?.email]);
const changeLanguage = React.useCallback(async (locale: SupportedLocale) => {
if (locale === currentLocale) {
return;
}
setPendingLocale(locale);
try {
await switchLocale(locale);
} finally {
setPendingLocale(null);
}
}, [currentLocale]);
const changeAppearance = React.useCallback(
(mode: 'light' | 'dark' | 'system') => {
updateAppearance(mode);
},
[updateAppearance]
);
const goTo = React.useCallback((path: string) => navigate(path), [navigate]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 rounded-full border border-transparent px-2">
<Avatar className="h-8 w-8">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold sm:inline">
{user?.name || user?.email || t('app.userMenu')}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>
<p className="text-sm font-semibold">{user?.name ?? t('user.unknown')}</p>
{user?.email ? <p className="text-xs text-slate-500">{user.email}</p> : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => goTo(ADMIN_PROFILE_PATH)}>
<User className="h-4 w-4" />
{t('navigation.profile', { defaultValue: 'Profil' })}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{t('navigation.settings', { defaultValue: 'Einstellungen' })}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_BILLING_PATH)}>
<CreditCard className="h-4 w-4" />
{t('navigation.billing', { defaultValue: 'Billing' })}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="h-4 w-4" />
<span>{t('app.languageSwitch')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent align="end">
{SUPPORTED_LANGUAGES.map(({ code, labelKey }) => (
<DropdownMenuItem
key={code}
className="flex items-center justify-between"
onSelect={(event) => {
event.preventDefault();
changeLanguage(code);
}}
disabled={pendingLocale === code}
>
<span>{t(labelKey)}</span>
{currentLocale === code ? <span className="text-xs text-rose-500">{t('app.languageActive', { defaultValue: 'Aktiv' })}</span> : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{appearance === 'dark' ? <Moon className="h-4 w-4" /> : appearance === 'light' ? <Sun className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
<span>{t('app.theme', { defaultValue: 'Darstellung' })}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent align="end">
{(['light', 'dark', 'system'] as const).map((mode) => (
<DropdownMenuItem key={mode} onSelect={() => changeAppearance(mode)}>
{mode === 'light' ? <Sun className="h-4 w-4" /> : mode === 'dark' ? <Moon className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
<span>{t(`app.theme_${mode}`, { defaultValue: mode })}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => goTo(ADMIN_FAQ_PATH)}>
<HelpCircle className="h-4 w-4" />
{t('app.help', { defaultValue: 'FAQ & Hilfe' })}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={(event) => {
event.preventDefault();
logout();
}}
>
<LogOut className="h-4 w-4" />
{t('app.logout', { defaultValue: 'Abmelden' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -11,6 +11,7 @@ export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/events'); export const ADMIN_EVENTS_PATH = adminPath('/events');
export const ADMIN_SETTINGS_PATH = adminPath('/settings'); export const ADMIN_SETTINGS_PATH = adminPath('/settings');
export const ADMIN_PROFILE_PATH = adminPath('/settings/profile'); export const ADMIN_PROFILE_PATH = adminPath('/settings/profile');
export const ADMIN_FAQ_PATH = adminPath('/faq');
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement'); export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string => export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`; `${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { getEvents, type TenantEvent } from '../api';
const STORAGE_KEY = 'tenant-admin.active-event';
export interface EventContextValue {
events: TenantEvent[];
isLoading: boolean;
activeEvent: TenantEvent | null;
selectEvent: (slug: string | null) => void;
}
const EventContext = React.createContext<EventContextValue | undefined>(undefined);
export function EventProvider({ children }: { children: React.ReactNode }) {
const [storedSlug, setStoredSlug] = React.useState<string | null>(() => {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem(STORAGE_KEY);
});
const { data: events = [], isLoading } = useQuery<TenantEvent[]>({
queryKey: ['tenant-events'],
queryFn: async () => {
try {
return await getEvents();
} catch (error) {
console.warn('[EventContext] Failed to fetch events', error);
return [];
}
},
staleTime: 60 * 1000,
cacheTime: 5 * 60 * 1000,
});
React.useEffect(() => {
if (!storedSlug && events.length === 1 && events[0]?.slug && typeof window !== 'undefined') {
setStoredSlug(events[0].slug);
window.localStorage.setItem(STORAGE_KEY, events[0].slug);
}
}, [events, storedSlug]);
const activeEvent = React.useMemo(() => {
if (!events.length) {
return null;
}
const matched = events.find((event) => event.slug && event.slug === storedSlug);
if (matched) {
return matched;
}
if (!storedSlug && events.length === 1) {
return events[0];
}
return null;
}, [events, storedSlug]);
const selectEvent = React.useCallback((slug: string | null) => {
setStoredSlug(slug);
if (typeof window !== 'undefined') {
if (slug) {
window.localStorage.setItem(STORAGE_KEY, slug);
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
}
}, []);
const value = React.useMemo<EventContextValue>(
() => ({
events,
isLoading,
activeEvent,
selectEvent,
}),
[events, isLoading, activeEvent, selectEvent]
);
return <EventContext.Provider value={value}>{children}</EventContext.Provider>;
}
export function useEventContext(): EventContextValue {
const ctx = React.useContext(EventContext);
if (!ctx) {
throw new Error('useEventContext must be used within an EventProvider');
}
return ctx;
}

View File

@@ -1,18 +1,50 @@
{ {
"app": { "app": {
"brand": "Fotospiel Tenant Admin", "brand": "Fotospiel Tenant Admin",
"languageSwitch": "Sprache" "languageSwitch": "Sprache",
"userMenu": "Konto",
"help": "FAQ & Hilfe",
"logout": "Abmelden",
"theme": "Darstellung",
"theme_light": "Hell",
"theme_dark": "Dunkel",
"theme_system": "System",
"languageActive": "Aktiv"
}, },
"navigation": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"event": "Event",
"events": "Events", "events": "Events",
"photos": "Fotos",
"tasks": "Aufgaben", "tasks": "Aufgaben",
"collections": "Aufgabenvorlagen", "collections": "Aufgabenvorlagen",
"emotions": "Emotionen", "emotions": "Emotionen",
"engagement": "Aufgaben & Co.", "engagement": "Aufgaben & Co.",
"toolkit": "Toolkit",
"billing": "Abrechnung", "billing": "Abrechnung",
"settings": "Einstellungen" "settings": "Einstellungen"
}, },
"eventMenu": {
"summary": "Übersicht",
"photos": "Uploads",
"guests": "Team & Gäste",
"tasks": "Aufgaben",
"invites": "Einladungen",
"toolkit": "Toolkit"
},
"eventSwitcher": {
"title": "Event auswählen",
"description": "Wähle ein Event zur Bearbeitung oder lege ein neues an.",
"placeholder": "Event auswählen",
"multiple": "Mehrere Events",
"empty": "Kein Event",
"emptyDescription": "Erstelle dein erstes Event, um loszulegen.",
"noEvents": "Noch keine Events vorhanden.",
"noDate": "Kein Datum",
"active": "Aktiv",
"create": "Neues Event anlegen",
"actions": "Event-Funktionen"
},
"language": { "language": {
"de": "Deutsch", "de": "Deutsch",
"en": "Englisch" "en": "Englisch"

View File

@@ -36,6 +36,17 @@
"lowCredits": "Auffüllen empfohlen" "lowCredits": "Auffüllen empfohlen"
} }
}, },
"liveNow": {
"title": "Während des Events",
"description": "Direkter Zugriff, solange {{count}} Event(s) live sind.",
"status": "Jetzt live",
"noDate": "Kein Datum",
"actions": {
"photos": "Uploads",
"invites": "QR & Einladungen",
"tasks": "Aufgaben"
}
},
"readiness": { "readiness": {
"title": "Bereit für den Eventstart", "title": "Bereit für den Eventstart",
"description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.", "description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.",
@@ -112,6 +123,31 @@
"noDate": "Kein Datum" "noDate": "Kein Datum"
} }
}, },
"faq": {
"title": "FAQ & Hilfe",
"subtitle": "Antworten und Hinweise rund um den Tenant Admin.",
"intro": {
"title": "Was dich erwartet",
"description": "Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt."
},
"events": {
"question": "Wie arbeite ich mit Events?",
"answer": "Wähle dein aktives Event, passe Aufgaben an und teile Einladungen. Ausführliche Dokumentation folgt."
},
"uploads": {
"question": "Wie moderiere ich Uploads?",
"answer": "Sobald Fotos eintreffen, findest du sie in der Event-Galerie und kannst sie freigeben oder ablehnen."
},
"support": {
"question": "Wo erhalte ich Support?",
"answer": "Dieses FAQ ist ein Platzhalter. Nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank live ist."
},
"cta": {
"needHelp": "Fehlt dir etwas?",
"description": "Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail wir ergänzen dieses FAQ mit deinen Themen.",
"contact": "Support kontaktieren"
}
},
"dashboard": { "dashboard": {
"actions": { "actions": {
"newEvent": "Neues Event", "newEvent": "Neues Event",

View File

@@ -1,18 +1,50 @@
{ {
"app": { "app": {
"brand": "Fotospiel Tenant Admin", "brand": "Fotospiel Tenant Admin",
"languageSwitch": "Language" "languageSwitch": "Language",
"userMenu": "Account",
"help": "FAQ & Help",
"logout": "Log out",
"theme": "Appearance",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System",
"languageActive": "Active"
}, },
"navigation": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"event": "Event",
"events": "Events", "events": "Events",
"photos": "Photos",
"tasks": "Tasks", "tasks": "Tasks",
"collections": "Collections", "collections": "Collections",
"emotions": "Emotions", "emotions": "Emotions",
"engagement": "Tasks & More", "engagement": "Tasks & More",
"toolkit": "Toolkit",
"billing": "Billing", "billing": "Billing",
"settings": "Settings" "settings": "Settings"
}, },
"eventMenu": {
"summary": "Overview",
"photos": "Uploads",
"guests": "Members",
"tasks": "Tasks",
"invites": "Invites",
"toolkit": "Toolkit"
},
"eventSwitcher": {
"title": "Select event",
"description": "Choose an event to work on or create a new one.",
"placeholder": "Select event",
"multiple": "Multiple events",
"empty": "No event",
"emptyDescription": "Create your first event to get started.",
"noEvents": "No events yet.",
"noDate": "No date",
"active": "Active",
"create": "Create new event",
"actions": "Event tools"
},
"language": { "language": {
"de": "German", "de": "German",
"en": "English" "en": "English"

View File

@@ -36,6 +36,17 @@
"lowCredits": "Top up recommended" "lowCredits": "Top up recommended"
} }
}, },
"liveNow": {
"title": "During the event",
"description": "Quick actions while {{count}} event(s) are live.",
"status": "Live now",
"noDate": "No date",
"actions": {
"photos": "Live uploads",
"invites": "QR & invites",
"tasks": "Tasks"
}
},
"readiness": { "readiness": {
"title": "Ready for event day", "title": "Ready for event day",
"description": "Complete these steps so guests can join without friction.", "description": "Complete these steps so guests can join without friction.",
@@ -112,6 +123,31 @@
"noDate": "No date" "noDate": "No date"
} }
}, },
"faq": {
"title": "FAQ & Help",
"subtitle": "Answers and hints around the tenant admin.",
"intro": {
"title": "What to expect",
"description": "We are collecting feedback and will expand this help center step by step."
},
"events": {
"question": "How do I work with events?",
"answer": "Select your active event, adjust tasks, and share invites. More documentation will follow soon."
},
"uploads": {
"question": "How do I moderate uploads?",
"answer": "Once photos arrive you can review them in the event gallery and approve or reject them."
},
"support": {
"question": "Where do I get support?",
"answer": "This FAQ is a placeholder. Please reach out through the known support channel until the knowledge base ships."
},
"cta": {
"needHelp": "Missing something?",
"description": "Send us your feedback straight from the admin or via support mail well extend this FAQ with your topics.",
"contact": "Contact support"
}
},
"dashboard": { "dashboard": {
"actions": { "actions": {
"newEvent": "New Event", "newEvent": "New Event",

View File

@@ -0,0 +1,40 @@
import i18n from '../i18n';
export type SupportedLocale = 'de' | 'en';
export const SUPPORTED_LANGUAGES: Array<{ code: SupportedLocale; labelKey: string }> = [
{ code: 'de', labelKey: 'language.de' },
{ code: 'en', labelKey: 'language.en' },
];
function getCsrfToken(): string {
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
}
async function persistLocale(locale: SupportedLocale): Promise<void> {
const response = await fetch('/set-locale', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ locale }),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`locale update failed with status ${response.status}`);
}
}
export function getCurrentLocale(): SupportedLocale {
return (i18n.language || document.documentElement.lang || 'de') as SupportedLocale;
}
export async function switchLocale(locale: SupportedLocale): Promise<void> {
await persistLocale(locale);
await i18n.changeLanguage(locale);
document.documentElement.setAttribute('lang', locale);
}

View File

@@ -9,6 +9,7 @@ import './i18n';
import './dev-tools'; import './dev-tools';
import { initializeTheme } from '@/hooks/use-appearance'; import { initializeTheme } from '@/hooks/use-appearance';
import { OnboardingProgressProvider } from './onboarding'; import { OnboardingProgressProvider } from './onboarding';
import { EventProvider } from './context/EventContext';
const DevTenantSwitcher = React.lazy(() => import('./components/DevTenantSwitcher')); const DevTenantSwitcher = React.lazy(() => import('./components/DevTenantSwitcher'));
@@ -41,17 +42,19 @@ createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<OnboardingProgressProvider> <EventProvider>
<Suspense <OnboardingProgressProvider>
fallback={( <Suspense
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground"> fallback={(
Oberfläche wird geladen <div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
</div> Oberfläche wird geladen
)} </div>
> )}
<RouterProvider router={router} /> >
</Suspense> <RouterProvider router={router} />
</OnboardingProgressProvider> </Suspense>
</OnboardingProgressProvider>
</EventProvider>
</AuthProvider> </AuthProvider>
{enableDevSwitcher ? ( {enableDevSwitcher ? (
<Suspense fallback={null}> <Suspense fallback={null}>

View File

@@ -12,7 +12,6 @@ import {
QrCode, QrCode,
ClipboardList, ClipboardList,
Package as PackageIcon, Package as PackageIcon,
ArrowUpRight,
} from 'lucide-react'; } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -26,7 +25,6 @@ import {
TenantOnboardingChecklistCard, TenantOnboardingChecklistCard,
FrostedSurface, FrostedSurface,
tenantHeroPrimaryButtonClass, tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard, SectionCard,
SectionHeader, SectionHeader,
StatCarousel, StatCarousel,
@@ -52,6 +50,7 @@ import {
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_BILLING_PATH, ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH, ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_BASE_PATH,
@@ -212,7 +211,7 @@ export default function DashboardPage() {
meta: primary ? { event_id: primary.id } : undefined, meta: primary ? { event_id: primary.id } : undefined,
}); });
} }
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]); }, [loading, events, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
const greetingName = user?.name ?? translate('welcome.fallbackName'); const greetingName = user?.name ?? translate('welcome.fallbackName');
const greetingTitle = translate('welcome.greeting', { name: greetingName }); const greetingTitle = translate('welcome.greeting', { name: greetingName });
@@ -224,6 +223,9 @@ export default function DashboardPage() {
const publishedEvents = events.filter((event) => event.status === 'published'); const publishedEvents = events.filter((event) => event.status === 'published');
const primaryEvent = events[0] ?? null; const primaryEvent = events[0] ?? null;
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null; const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
const singleEvent = events.length === 1 ? events[0] : null;
const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null;
const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : null;
const primaryEventLimits = primaryEvent?.limits ?? null; const primaryEventLimits = primaryEvent?.limits ?? null;
const limitTranslate = React.useCallback( const limitTranslate = React.useCallback(
@@ -271,6 +273,31 @@ export default function DashboardPage() {
}, [summary, events]); }, [summary, events]);
const primaryEventSlug = readiness.primaryEventSlug; const primaryEventSlug = readiness.primaryEventSlug;
const liveEvents = React.useMemo(() => {
const now = Date.now();
const windowLengthMs = 2 * 24 * 60 * 60 * 1000; // event day + following day
return events.filter((event) => {
if (!event.slug) {
return false;
}
const isActivated = Boolean(event.is_active || event.status === 'published');
if (!isActivated) {
return false;
}
if (!event.event_date) {
return true;
}
const eventStart = new Date(event.event_date).getTime();
if (Number.isNaN(eventStart)) {
return true;
}
return now >= eventStart && now <= eventStart + windowLengthMs;
});
}, [events]);
const statItems = React.useMemo( const statItems = React.useMemo(
() => ([ () => ([
{ {
@@ -430,46 +457,77 @@ export default function DashboardPage() {
'Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.' 'Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.'
); );
const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten'); const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten');
const heroBadge = translate('overview.title', 'Kurzer Überblick');
const heroDescription = translate( const heroBadge = singleEvent
'overview.description', ? translate('overview.eventHero.badge', 'Aktives Event')
'Wichtigste Kennzahlen deines Tenants auf einen Blick.' : translate('overview.title', 'Kurzer Überblick');
);
const marketingDashboardLabel = translate('onboarding.back_to_marketing', 'Marketing-Dashboard ansehen'); const heroDescription = singleEvent
const marketingDashboardDescription = translate( ? translate('overview.eventHero.description', 'Alles richtet sich nach {{event}}. Nächster Termin: {{date}}.', {
'onboarding.back_to_marketing_description', event: singleEventName ?? '',
'Zur Zusammenfassung im Kundenportal wechseln.' date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
); })
: translate('overview.description', 'Wichtigste Kennzahlen deines Tenants auf einen Blick.');
const heroSupportingCopy = onboardingCompletion === 100 ? onboardingCompletedCopy : onboardingCardDescription; const heroSupportingCopy = onboardingCompletion === 100 ? onboardingCompletedCopy : onboardingCardDescription;
const heroPrimaryCtaLabel = readiness.hasEvent const heroSupporting = singleEvent
? translate('quickActions.moderatePhotos.label', 'Fotos moderieren') ? [
: translate('actions.newEvent'); translate('overview.eventHero.supporting.status', 'Status: {{status}}', {
const heroPrimaryAction = ( status: formatEventStatus(singleEvent.status ?? null, tc),
<Button }),
size="sm" singleEventDateLabel
className={tenantHeroPrimaryButtonClass} ? translate('overview.eventHero.supporting.date', 'Eventdatum: {{date}}', { date: singleEventDateLabel })
onClick={() => { : translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt.'),
if (readiness.hasEvent) { ].filter(Boolean)
navigate(ADMIN_EVENTS_PATH); : [heroSupportingCopy];
} else {
navigate(ADMIN_EVENT_CREATE_PATH); const heroPrimaryAction = (() => {
} if (onboardingCompletion < 100) {
}} return (
> <Button
{heroPrimaryCtaLabel} size="sm"
</Button> className={tenantHeroPrimaryButtonClass}
); onClick={() => {
const heroSecondaryAction = ( if (readiness.hasEvent) {
<Button navigate(ADMIN_EVENTS_PATH);
size="sm" } else {
className={tenantHeroSecondaryButtonClass} navigate(ADMIN_EVENT_CREATE_PATH);
onClick={() => window.location.assign('/dashboard')} }
> }}
{marketingDashboardLabel} >
<ArrowUpRight className="ml-2 h-4 w-4" /> {translate('onboarding.hero.cta', 'Setup fortsetzen')}
</Button> </Button>
); );
const heroAside = ( }
if (singleEvent?.slug) {
return (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug))}
>
{translate('actions.openEvent', 'Event öffnen')}
</Button>
);
}
if (readiness.hasEvent) {
return (
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENTS_PATH)}>
{translate('quickActions.moderatePhotos.label', 'Fotos moderieren')}
</Button>
);
}
return (
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}>
{translate('actions.newEvent')}
</Button>
);
})();
const heroAside = onboardingCompletion < 100 ? (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10"> <FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div className="flex items-center justify-between text-sm font-medium text-slate-700"> <div className="flex items-center justify-between text-sm font-medium text-slate-700">
<span>{onboardingCardTitle}</span> <span>{onboardingCardTitle}</span>
@@ -480,9 +538,44 @@ export default function DashboardPage() {
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" /> <Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p> <p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
</FrostedSurface> </FrostedSurface>
); ) : singleEvent ? (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div className="space-y-3 text-sm">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-rose-500">
{translate('overview.eventHero.stats.title', 'Momentaufnahme')}
</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{formatEventStatus(singleEvent.status ?? null, tc)}
</p>
</div>
<dl className="space-y-2">
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.date', 'Eventdatum')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Nicht gesetzt')}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.uploads', 'Uploads gesamt')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{Number(singleEvent.photo_count ?? 0).toLocaleString(i18n.language)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.tasks', 'Offene Aufgaben')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{Number(singleEvent.tasks_count ?? 0).toLocaleString(i18n.language)}
</dd>
</div>
</dl>
</div>
</FrostedSurface>
) : null;
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt'); const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
const readinessPendingLabel = translate('readiness.pending', 'Noch offen'); const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
const hasEventContext = readiness.hasEvent;
const quickActionItems = React.useMemo( const quickActionItems = React.useMemo(
() => [ () => [
{ {
@@ -498,6 +591,7 @@ export default function DashboardPage() {
description: translate('quickActions.moderatePhotos.description'), description: translate('quickActions.moderatePhotos.description'),
icon: <Camera className="h-5 w-5" />, icon: <Camera className="h-5 w-5" />,
onClick: () => navigate(ADMIN_EVENTS_PATH), onClick: () => navigate(ADMIN_EVENTS_PATH),
disabled: !hasEventContext,
}, },
{ {
key: 'tasks', key: 'tasks',
@@ -505,6 +599,7 @@ export default function DashboardPage() {
description: translate('quickActions.organiseTasks.description'), description: translate('quickActions.organiseTasks.description'),
icon: <ClipboardList className="h-5 w-5" />, icon: <ClipboardList className="h-5 w-5" />,
onClick: () => navigate(buildEngagementTabPath('tasks')), onClick: () => navigate(buildEngagementTabPath('tasks')),
disabled: !hasEventContext,
}, },
{ {
key: 'packages', key: 'packages',
@@ -513,18 +608,24 @@ export default function DashboardPage() {
icon: <Sparkles className="h-5 w-5" />, icon: <Sparkles className="h-5 w-5" />,
onClick: () => navigate(ADMIN_BILLING_PATH), onClick: () => navigate(ADMIN_BILLING_PATH),
}, },
{
key: 'marketing',
label: marketingDashboardLabel,
description: marketingDashboardDescription,
icon: <ArrowUpRight className="h-5 w-5" />,
onClick: () => window.location.assign('/dashboard'),
},
], ],
[translate, navigate, marketingDashboardLabel, marketingDashboardDescription], [translate, navigate, hasEventContext],
); );
const layoutActions = ( const layoutActions = singleEvent ? (
<Button
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => {
if (singleEvent.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug));
} else {
navigate(ADMIN_EVENTS_PATH);
}
}}
>
{translate('actions.openEvent', 'Event öffnen')}
</Button>
) : (
<Button <Button
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]" className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)} onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
@@ -533,8 +634,29 @@ export default function DashboardPage() {
</Button> </Button>
); );
const adminTitle = singleEventName ?? greetingTitle;
const adminSubtitle = singleEvent
? translate('overview.eventHero.subtitle', 'Alle Funktionen konzentrieren sich auf dieses Event.', {
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
})
: subtitle;
const heroTitle = adminTitle;
const liveNowTitle = t('liveNow.title', { defaultValue: 'Während des Events' });
const liveNowDescription = t('liveNow.description', {
defaultValue: 'Direkter Zugriff, solange dein Event läuft.',
count: liveEvents.length,
});
const liveActionLabels = React.useMemo(() => ({
photos: t('liveNow.actions.photos', { defaultValue: 'Uploads' }),
invites: t('liveNow.actions.invites', { defaultValue: 'QR & Einladungen' }),
tasks: t('liveNow.actions.tasks', { defaultValue: 'Aufgaben' }),
}), [t]);
const liveStatusLabel = t('liveNow.status', { defaultValue: 'Live' });
const liveNoDate = t('liveNow.noDate', { defaultValue: 'Kein Datum' });
return ( return (
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={layoutActions}> <AdminLayout title={adminTitle} subtitle={adminSubtitle} actions={layoutActions}>
{errorMessage && ( {errorMessage && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle> <AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
@@ -548,14 +670,74 @@ export default function DashboardPage() {
<> <>
<TenantHeroCard <TenantHeroCard
badge={heroBadge} badge={heroBadge}
title={greetingTitle} title={heroTitle}
description={heroDescription} description={heroDescription}
supporting={[heroSupportingCopy]} supporting={heroSupporting}
primaryAction={heroPrimaryAction} primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside} aside={heroAside}
/> />
{liveEvents.length > 0 && (
<Card className="border border-rose-200 bg-rose-50/80 shadow-lg shadow-rose-200/40">
<CardHeader className="space-y-1">
<CardTitle className="text-base font-semibold text-rose-900">{liveNowTitle}</CardTitle>
<CardDescription className="text-sm text-rose-700">{liveNowDescription}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{liveEvents.map((event) => {
const name = resolveEventName(event.name, event.slug);
const dateLabel = event.event_date ? formatDate(event.event_date, dateLocale) : liveNoDate;
return (
<div
key={event.id}
className="rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm shadow-rose-100/50"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{name}</p>
<p className="text-xs text-slate-500">{dateLabel}</p>
</div>
<Badge className="bg-rose-600/90 text-white">{liveStatusLabel}</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
>
<Camera className="h-4 w-4" />
{liveActionLabels.photos}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
>
<QrCode className="h-4 w-4" />
{liveActionLabels.invites}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
>
<ClipboardList className="h-4 w-4" />
{liveActionLabels.tasks}
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
{events.length === 0 && ( {events.length === 0 && (
<Card className="border-none bg-white/90 shadow-lg shadow-rose-100/50"> <Card className="border-none bg-white/90 shadow-lg shadow-rose-100/50">
<CardHeader className="space-y-2"> <CardHeader className="space-y-2">
@@ -752,6 +934,17 @@ function formatDate(value: string | null, locale: string): string | null {
} }
} }
function formatEventStatus(status: TenantEvent['status'] | null, translateFn: (key: string, options?: Record<string, unknown>) => string): string {
const map: Record<string, { key: string; fallback: string }> = {
published: { key: 'events.status.published', fallback: 'Veröffentlicht' },
draft: { key: 'events.status.draft', fallback: 'Entwurf' },
archived: { key: 'events.status.archived', fallback: 'Archiviert' },
};
const target = map[status ?? 'draft'] ?? map.draft;
return translateFn(target.key, { defaultValue: target.fallback });
}
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string { function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
if (typeof name === 'string' && name.trim().length > 0) { if (typeof name === 'string' && name.trim().length > 0) {
return name; return name;
@@ -914,29 +1107,6 @@ function GalleryStatusRow({
); );
} }
function StatCard({
label,
value,
hint,
icon,
}: {
label: string;
value: string | number;
hint?: string;
icon: React.ReactNode;
}) {
return (
<FrostedSurface className="border-brand-rose-soft/40 p-5 shadow-md shadow-pink-100/30 transition-transform duration-200 ease-out hover:-translate-y-0.5 hover:shadow-lg hover:shadow-rose-300/30">
<div className="flex items-center justify-between">
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
</div>
<div className="mt-4 text-2xl font-semibold text-slate-900 dark:text-slate-100">{value}</div>
{hint && <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{hint}</p>}
</FrostedSurface>
);
}
function UpcomingEventRow({ function UpcomingEventRow({
event, event,
onView, onView,

View File

@@ -6,9 +6,7 @@ import {
ArrowLeft, ArrowLeft,
Camera, Camera,
CheckCircle2, CheckCircle2,
ChevronRight,
Circle, Circle,
Download,
Loader2, Loader2,
MessageSquare, MessageSquare,
Printer, Printer,
@@ -23,8 +21,6 @@ import toast from 'react-hot-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { import {
EventToolkit, EventToolkit,
@@ -54,6 +50,7 @@ import {
SectionCard, SectionCard,
SectionHeader, SectionHeader,
ActionGrid, ActionGrid,
TenantHeroCard,
} from '../components/tenant'; } from '../components/tenant';
type EventDetailPageProps = { type EventDetailPageProps = {
@@ -175,48 +172,6 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.') ? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.'); : t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const actions = (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="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>
{event && (
<>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="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" onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
<Users className="h-4 w-4" /> {t('events.actions.members', 'Team & Rollen')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.tasks', 'Aufgaben verwalten')}
</Button>
<Button variant="outline" onClick={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} className="border-amber-200 text-amber-600 hover:bg-amber-50">
<QrCode className="h-4 w-4" /> {t('events.actions.invites', 'Einladungen & Layouts')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Camera className="h-4 w-4" /> {t('events.actions.photos', 'Fotos moderieren')}
</Button>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</>
)}
</div>
);
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.')} actions={actions}>
<SectionCard>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</p>
</SectionCard>
</AdminLayout>
);
}
const limitWarnings = React.useMemo( const limitWarnings = React.useMemo(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []), () => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
@@ -240,8 +195,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
}); });
}, [limitWarnings]); }, [limitWarnings]);
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.')}
>
<SectionCard>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</p>
</SectionCard>
</AdminLayout>
);
}
return ( return (
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}> <AdminLayout title={eventName} subtitle={subtitle}>
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle> <AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
@@ -276,6 +246,14 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
<WorkspaceSkeleton /> <WorkspaceSkeleton />
) : event ? ( ) : event ? (
<div className="space-y-6"> <div className="space-y-6">
<EventHeroCardSection
event={event}
stats={stats}
onRefresh={() => { void load(); }}
loading={state.busy}
navigate={navigate}
/>
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />} {(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
@@ -332,14 +310,82 @@ function resolveName(name: TenantEvent['name']): string {
return 'Event'; return 'Event';
} }
function EventHeroCardSection({ event, stats, onRefresh, loading, navigate }: {
event: TenantEvent;
stats: EventStats | null;
onRefresh: () => void;
loading: boolean;
navigate: ReturnType<typeof useNavigate>;
}) {
const { t } = useTranslation('management');
const statusLabel = getStatusLabel(event, t);
const supporting = [
t('events.workspace.hero.status', { defaultValue: 'Status: {{status}}', status: statusLabel }),
t('events.workspace.hero.date', { defaultValue: 'Eventdatum: {{date}}', date: formatDate(event.event_date) }),
t('events.workspace.hero.metrics', {
defaultValue: 'Uploads gesamt: {{count}} · Likes: {{likes}}',
count: stats?.uploads_total ?? stats?.total ?? 0,
likes: stats?.likes_total ?? stats?.likes ?? 0,
}),
];
const aside = (
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">
<InfoRow
icon={<Sparkles className="h-4 w-4 text-pink-500" />}
label={t('events.workspace.fields.status', 'Status')}
value={statusLabel}
/>
<InfoRow
icon={<CalendarIcon />}
label={t('events.workspace.fields.date', 'Eventdatum')}
value={formatDate(event.event_date)}
/>
<InfoRow
icon={<Users className="h-4 w-4 text-sky-500" />}
label={t('events.workspace.fields.active', 'Aktiv für Gäste')}
value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')}
/>
</div>
);
return (
<TenantHeroCard
badge={t('events.workspace.hero.badge', 'Event')}
title={resolveName(event.name)}
description={t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
supporting={supporting}
primaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} 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>
)}
secondaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} 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>
)}
aside={aside}
>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={onRefresh}
disabled={loading}
className="rounded-full border-slate-200"
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</div>
</TenantHeroCard>
);
}
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) { function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const statusLabel = event.status === 'published' const statusLabel = getStatusLabel(event, t);
? t('events.status.published', 'Veröffentlicht')
: event.status === 'draft'
? t('events.status.draft', 'Entwurf')
: t('events.status.archived', 'Archiviert');
return ( return (
<SectionCard className="space-y-4"> <SectionCard className="space-y-4">
@@ -839,6 +885,16 @@ function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string;
); );
} }
function getStatusLabel(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
if (event.status === 'published') {
return t('events.status.published', 'Veröffentlicht');
}
if (event.status === 'archived') {
return t('events.status.archived', 'Archiviert');
}
return t('events.status.draft', 'Entwurf');
}
function formatDate(value: string | null | undefined): string { function formatDate(value: string | null | undefined): string {
if (!value) return '—'; if (!value) return '—';
const date = new Date(value); const date = new Date(value);

View File

@@ -67,7 +67,17 @@ export default function EventTasksPage() {
setEvent(eventData); setEvent(eventData);
const assignedIds = new Set(eventTasksResponse.data.map((task) => task.id)); const assignedIds = new Set(eventTasksResponse.data.map((task) => task.id));
setAssignedTasks(eventTasksResponse.data); setAssignedTasks(eventTasksResponse.data);
setAvailableTasks(libraryTasks.data.filter((task) => !assignedIds.has(task.id))); const eventTypeId = eventData.event_type_id ?? null;
const filteredLibraryTasks = libraryTasks.data.filter((task) => {
if (assignedIds.has(task.id)) {
return false;
}
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) {
return false;
}
return true;
});
setAvailableTasks(filteredLibraryTasks);
setError(null); setError(null);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -104,6 +114,10 @@ export default function EventTasksPage() {
} }
} }
React.useEffect(() => {
setSelected((current) => current.filter((taskId) => availableTasks.some((task) => task.id === taskId)));
}, [availableTasks]);
const isPhotoOnlyMode = event?.engagement_mode === 'photo_only'; const isPhotoOnlyMode = event?.engagement_mode === 'photo_only';
async function handleModeChange(checked: boolean) { async function handleModeChange(checked: boolean) {

View File

@@ -24,6 +24,7 @@ import { getApiErrorMessage } from '../lib/apiError';
import { import {
adminPath, adminPath,
ADMIN_SETTINGS_PATH, ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_EDIT_PATH, ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_PHOTOS_PATH,
@@ -69,6 +70,25 @@ export default function EventsPage() {
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }), tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
[tCommon], [tCommon],
); );
const totalEvents = rows.length;
const publishedEvents = React.useMemo(
() => rows.filter((event) => event.status === 'published').length,
[rows],
);
const nextEvent = React.useMemo(() => {
return (
rows
.filter((event) => event.event_date)
.slice()
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
return dateA - dateB;
})[0] ?? null
);
}, [rows]);
const statItems = React.useMemo( const statItems = React.useMemo(
() => [ () => [
{ {
@@ -125,18 +145,6 @@ export default function EventsPage() {
'events.list.subtitle', 'events.list.subtitle',
'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.' 'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.'
); );
const totalEvents = rows.length;
const publishedEvents = React.useMemo(() => rows.filter((event) => event.status === 'published').length, [rows]);
const nextEvent = React.useMemo(() => {
return rows
.filter((event) => event.event_date)
.slice()
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
return dateA - dateB;
})[0] ?? null;
}, [rows]);
const heroDescription = t( const heroDescription = t(
'events.list.hero.description', 'events.list.hero.description',
'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.' 'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.'

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AdminLayout } from '../components/AdminLayout';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export default function FaqPage() {
const { t } = useTranslation('dashboard');
const entries = [
{
question: t('faq.events.question', 'Wie arbeite ich mit Events?'),
answer: t(
'faq.events.answer',
'Wähle dein aktives Event, passe Aufgaben an und lade Gäste über die Einladungsseite ein. Weitere Dokumentation folgt bald.'
),
},
{
question: t('faq.uploads.question', 'Wie moderiere ich Uploads?'),
answer: t(
'faq.uploads.answer',
'Sobald Fotos eintreffen, findest du sie in der Galerie-Ansicht deines Events. Von dort kannst du sie freigeben oder zurückweisen.'
),
},
{
question: t('faq.support.question', 'Wo erhalte ich Support?'),
answer: t(
'faq.support.answer',
'Dieses FAQ dient als Platzhalter. Bitte nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank veröffentlicht wird.'
),
},
];
return (
<AdminLayout
title={t('faq.title', 'FAQ & Hilfe')}
subtitle={t('faq.subtitle', 'Antworten und Hinweise rund um den Tenant Admin.')}
>
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{t('faq.intro.title', 'Was dich erwartet')}</CardTitle>
<CardDescription>
{t(
'faq.intro.description',
'Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt.'
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{entries.map((entry) => (
<div key={entry.question} className="rounded-2xl border border-slate-200/80 p-4 dark:border-white/10">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{entry.question}</p>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{entry.answer}</p>
</div>
))}
<div className="rounded-2xl bg-rose-50/70 p-4 text-sm text-rose-900 dark:bg-rose-200/10 dark:text-rose-100">
<p className="font-semibold">
{t('faq.cta.needHelp', 'Fehlt dir etwas?')}
</p>
<p className="mt-1 text-sm">
{t(
'faq.cta.description',
'Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail wir erweitern dieses FAQ mit deinen Themen.'
)}
</p>
<Button
size="sm"
variant="secondary"
className="mt-3 rounded-full"
onClick={() => window.open('mailto:hello@fotospiel.app', '_blank')}
>
{t('faq.cta.contact', 'Support kontaktieren')}
</Button>
</div>
</CardContent>
</Card>
</AdminLayout>
);
}

View File

@@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next';
import { import {
AlignLeft, AlignLeft,
BadgeCheck, BadgeCheck,
ChevronDown,
Download, Download,
Heading, Heading,
Link as LinkIcon, Link as LinkIcon,
Loader2, Loader2,
Megaphone, Megaphone,
Minus,
Plus, Plus,
Printer, Printer,
QrCode, QrCode,
@@ -27,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { EventQrInvite, EventQrInviteLayout } from '../../api'; import type { EventQrInvite, EventQrInviteLayout } from '../../api';
@@ -241,6 +244,7 @@ export function InviteLayoutCustomizerPanel({
const [zoomScale, setZoomScale] = React.useState(1); const [zoomScale, setZoomScale] = React.useState(1);
const [fitScale, setFitScale] = React.useState(1); const [fitScale, setFitScale] = React.useState(1);
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit'); const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
const [isCompact, setIsCompact] = React.useState(false);
const fitScaleRef = React.useRef(1); const fitScaleRef = React.useRef(1);
const manualZoomRef = React.useRef(false); const manualZoomRef = React.useRef(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null); const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
@@ -252,6 +256,7 @@ export function InviteLayoutCustomizerPanel({
const designerViewportRef = React.useRef<HTMLDivElement | null>(null); const designerViewportRef = React.useRef<HTMLDivElement | null>(null);
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null); const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
const draftSignatureRef = React.useRef<string | null>(null); const draftSignatureRef = React.useRef<string | null>(null);
const initialElementsRef = React.useRef<LayoutElement[]>([]);
const activeCustomization = React.useMemo( const activeCustomization = React.useMemo(
() => draftCustomization ?? initialCustomization ?? null, () => draftCustomization ?? initialCustomization ?? null,
[draftCustomization, initialCustomization], [draftCustomization, initialCustomization],
@@ -264,6 +269,34 @@ export function InviteLayoutCustomizerPanel({
const appliedLayoutRef = React.useRef<string | null>(null); const appliedLayoutRef = React.useRef<string | null>(null);
const appliedInviteRef = React.useRef<number | string | null>(null); const appliedInviteRef = React.useRef<number | string | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
setIsCompact(false);
return;
}
const query = window.matchMedia('(max-width: 1023px)');
const update = (event?: MediaQueryListEvent) => {
if (typeof event?.matches === 'boolean') {
setIsCompact(event.matches);
return;
}
setIsCompact(query.matches);
};
update();
if (typeof query.addEventListener === 'function') {
const listener = (event: MediaQueryListEvent) => update(event);
query.addEventListener('change', listener);
return () => query.removeEventListener('change', listener);
}
const legacyListener = (event: MediaQueryListEvent) => update(event);
query.addListener(legacyListener);
return () => query.removeListener(legacyListener);
}, []);
const clampZoom = React.useCallback( const clampZoom = React.useCallback(
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX), (value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
[], [],
@@ -410,7 +443,8 @@ export function InviteLayoutCustomizerPanel({
const commitElements = React.useCallback( const commitElements = React.useCallback(
(producer: (current: LayoutElement[]) => LayoutElement[], options?: { silent?: boolean }) => { (producer: (current: LayoutElement[]) => LayoutElement[], options?: { silent?: boolean }) => {
setElements((prev) => { setElements((prev) => {
const base = cloneElements(prev); const source = prev.length ? prev : initialElementsRef.current;
const base = cloneElements(source.length ? source : []);
const produced = producer(base); const produced = producer(base);
const normalized = normalizeElements(produced); const normalized = normalizeElements(produced);
if (elementsAreEqual(prev, normalized)) { if (elementsAreEqual(prev, normalized)) {
@@ -514,6 +548,14 @@ export function InviteLayoutCustomizerPanel({
}, [clampZoom, zoomScale, fitScale, previewMode]); }, [clampZoom, zoomScale, fitScale, previewMode]);
const zoomPercent = Math.round(effectiveScale * 100); const zoomPercent = Math.round(effectiveScale * 100);
const handleZoomStep = React.useCallback(
(direction: 1 | -1) => {
manualZoomRef.current = true;
setZoomScale((current) => clampZoom(current + direction * ZOOM_STEP));
},
[clampZoom]
);
const updateElement = React.useCallback( const updateElement = React.useCallback(
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => { (id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
commitElements( commitElements(
@@ -646,6 +688,7 @@ export function InviteLayoutCustomizerPanel({
setInstructions([]); setInstructions([]);
commitElements(() => [], { silent: true }); commitElements(() => [], { silent: true });
resetHistory([]); resetHistory([]);
initialElementsRef.current = [];
appliedSignatureRef.current = null; appliedSignatureRef.current = null;
appliedLayoutRef.current = layoutId; appliedLayoutRef.current = layoutId;
appliedInviteRef.current = inviteKey; appliedInviteRef.current = inviteKey;
@@ -723,12 +766,15 @@ export function InviteLayoutCustomizerPanel({
if (isCustomizedAdvanced) { if (isCustomizedAdvanced) {
const initialElements = normalizeElements(payloadToElements(newForm.elements)); const initialElements = normalizeElements(payloadToElements(newForm.elements));
initialElementsRef.current = initialElements;
commitElements(() => initialElements, { silent: true }); commitElements(() => initialElements, { silent: true });
resetHistory(initialElements); resetHistory(initialElements);
} else { } else {
const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize); const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize);
commitElements(() => defaults, { silent: true }); const normalizedDefaults = normalizeElements(defaults);
resetHistory(defaults); initialElementsRef.current = normalizedDefaults;
commitElements(() => normalizedDefaults, { silent: true });
resetHistory(normalizedDefaults);
} }
appliedSignatureRef.current = incomingSignature ?? null; appliedSignatureRef.current = incomingSignature ?? null;
@@ -1515,6 +1561,38 @@ export function InviteLayoutCustomizerPanel({
const highlightedElementId = activeElementId ?? inspectorElementId; const highlightedElementId = activeElementId ?? inspectorElementId;
const renderResponsiveSection = React.useCallback(
(id: string, title: string, description: string, content: React.ReactNode) => {
const body = <div className="space-y-4">{content}</div>;
if (!isCompact) {
return (
<section key={id} className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<header className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</header>
{body}
</section>
);
}
return (
<Collapsible key={id} defaultOpen className="rounded-2xl border border-border bg-[var(--tenant-surface)] p-3 shadow-sm transition-colors">
<CollapsibleTrigger type="button" className="flex w-full items-center justify-between gap-3 text-left">
<div className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">{body}</CollapsibleContent>
</Collapsible>
);
},
[isCompact]
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex"> <div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
@@ -1525,63 +1603,58 @@ export function InviteLayoutCustomizerPanel({
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div> <div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
) : null} ) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]"> <div className="flex flex-col gap-6 xl:grid xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6"> <form ref={formRef} onSubmit={handleSubmit} className={cn('order-2 space-y-6', 'xl:order-1')}>
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors"> {renderResponsiveSection(
<header className="space-y-1"> 'layouts',
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t('invites.customizer.sections.layouts', 'Layouts')}</h3> t('invites.customizer.sections.layouts', 'Layouts'),
<p className="text-xs text-muted-foreground">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p> t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.'),
</header> <>
<Select
value={activeLayout?.id ?? undefined}
onValueChange={(value) => {
const layout = availableLayouts.find((item) => item.id === value);
if (layout) {
handleLayoutSelect(layout);
}
}}
disabled={!availableLayouts.length}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
</SelectTrigger>
<SelectContent className="max-h-60">
{availableLayouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
<div className="flex w-full flex-col gap-1 text-left">
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
{layout.formats?.length ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
</span>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select {activeLayout ? (
value={activeLayout?.id ?? undefined} <div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors">
onValueChange={(value) => { <p className="font-medium text-foreground">{activeLayout.name}</p>
const layout = availableLayouts.find((item) => item.id === value); {activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
if (layout) { {activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
handleLayoutSelect(layout); </div>
} ) : null}
}} </>
disabled={!availableLayouts.length} )}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
</SelectTrigger>
<SelectContent className="max-h-60">
{availableLayouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
<div className="flex w-full flex-col gap-1 text-left">
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
{layout.formats?.length ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
</span>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{activeLayout ? (
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors">
<p className="font-medium text-foreground">{activeLayout.name}</p>
{activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
{activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
</div>
) : null}
</section>
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<header className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('invites.customizer.elements.title', 'Elemente & Positionierung')}
</h3>
<p className="text-xs text-muted-foreground">
{t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.')}
</p>
</header>
{renderResponsiveSection(
'elements',
t('invites.customizer.elements.title', 'Elemente & Positionierung'),
t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.'),
<>
<div className="space-y-2"> <div className="space-y-2">
{sortedElements.map((element) => { {sortedElements.map((element) => {
const Icon = elementIconFor(element); const Icon = elementIconFor(element);
@@ -1652,11 +1725,15 @@ export function InviteLayoutCustomizerPanel({
{t('invites.customizer.elements.listHint', 'Wähle ein Element aus, um Einstellungen direkt unter dem Eintrag anzuzeigen.')} {t('invites.customizer.elements.listHint', 'Wähle ein Element aus, um Einstellungen direkt unter dem Eintrag anzuzeigen.')}
</p> </p>
</div> </div>
</section> </>
)}
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors"> {renderResponsiveSection(
'content',
t('invites.customizer.sections.content', 'Texte & Branding'),
t('invites.customizer.sections.contentHint', 'Passe Texte, Anleitungsschritte und Farben deiner Einladung an.'),
<Tabs defaultValue="text" className="space-y-4"> <Tabs defaultValue="text" className="space-y-4">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3 gap-1 text-xs sm:text-sm">
<TabsTrigger value="text">{t('invites.customizer.sections.text', 'Texte')}</TabsTrigger> <TabsTrigger value="text">{t('invites.customizer.sections.text', 'Texte')}</TabsTrigger>
<TabsTrigger value="instructions">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</TabsTrigger> <TabsTrigger value="instructions">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</TabsTrigger>
<TabsTrigger value="branding">{t('invites.customizer.sections.branding', 'Farbgebung')}</TabsTrigger> <TabsTrigger value="branding">{t('invites.customizer.sections.branding', 'Farbgebung')}</TabsTrigger>
@@ -1825,34 +1902,60 @@ export function InviteLayoutCustomizerPanel({
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</section> )}
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}> <div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
{renderActionButtons('inline')} {renderActionButtons('inline')}
</div> </div>
<div ref={actionsSentinelRef} className="h-1 w-full" /> <div ref={actionsSentinelRef} className="h-1 w-full" />
</form> </form>
<div className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors"> <div className={cn('order-1 flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors', 'xl:order-2')}>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{t('invites.customizer.controls.zoom', 'Zoom')} {t('invites.customizer.controls.zoom', 'Zoom')}
</span> </span>
<input {!isCompact ? (
type="range" <>
min={ZOOM_MIN} <input
max={ZOOM_MAX} type="range"
step={ZOOM_STEP} min={ZOOM_MIN}
value={effectiveScale} max={ZOOM_MAX}
onChange={(event) => { step={ZOOM_STEP}
manualZoomRef.current = true; value={effectiveScale}
setZoomScale(clampZoom(Number(event.target.value))); onChange={(event) => {
}} manualZoomRef.current = true;
className="h-1 w-36 overflow-hidden rounded-full" setZoomScale(clampZoom(Number(event.target.value)));
disabled={previewMode === 'full'} }}
aria-label={t('invites.customizer.controls.zoom', 'Zoom')} className="h-1 w-36 overflow-hidden rounded-full"
/> disabled={previewMode === 'full'}
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span> aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
/>
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
</>
) : (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleZoomStep(-1)}
aria-label={t('invites.customizer.controls.zoomOut', 'Verkleinern')}
>
<Minus className="h-4 w-4" />
</Button>
<span className="w-12 text-center text-xs font-medium tabular-nums text-muted-foreground">{zoomPercent}%</span>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleZoomStep(1)}
aria-label={t('invites.customizer.controls.zoomIn', 'Vergrößern')}
>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
<ToggleGroup type="single" value={previewMode} onValueChange={(val) => setPreviewMode(val as 'fit' | 'full')} className="flex"> <ToggleGroup type="single" value={previewMode} onValueChange={(val) => setPreviewMode(val as 'fit' | 'full')} className="flex">
<ToggleGroupItem value="fit" className="px-2 text-xs"> <ToggleGroupItem value="fit" className="px-2 text-xs">
Fit Fit
@@ -1861,20 +1964,37 @@ export function InviteLayoutCustomizerPanel({
100% 100%
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
<Button {!isCompact ? (
type="button" <Button
variant="ghost" type="button"
size="sm" variant="ghost"
onClick={() => { size="sm"
manualZoomRef.current = false; onClick={() => {
const fitValue = clampZoom(fitScaleRef.current); manualZoomRef.current = false;
setZoomScale(fitValue); const fitValue = clampZoom(fitScaleRef.current);
setPreviewMode('fit'); setZoomScale(fitValue);
}} setPreviewMode('fit');
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001} }}
> disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')} >
</Button> {t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
setPreviewMode('fit');
}}
aria-label={t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button

View File

@@ -312,11 +312,29 @@ export function DesignerCanvas({
canvas.on('selection:cleared', handleSelectionCleared); canvas.on('selection:cleared', handleSelectionCleared);
canvas.on('object:modified', handleObjectModified); canvas.on('object:modified', handleObjectModified);
const handleEditingExited = (event: { target?: FabricObjectWithId & { text?: string } }) => {
if (readOnly) {
return;
}
const target = event?.target;
if (!target || typeof target.elementId !== 'string') {
return;
}
const updatedText = typeof (target as fabric.Textbox).text === 'string' ? (target as fabric.Textbox).text : target.text ?? '';
handleObjectModified({ target });
onChange(target.elementId, { content: updatedText });
canvas.requestRenderAll();
};
canvas.on('editing:exited', handleEditingExited);
return () => { return () => {
canvas.off('selection:created', handleSelection); canvas.off('selection:created', handleSelection);
canvas.off('selection:updated', handleSelection); canvas.off('selection:updated', handleSelection);
canvas.off('selection:cleared', handleSelectionCleared); canvas.off('selection:cleared', handleSelectionCleared);
canvas.off('object:modified', handleObjectModified); canvas.off('object:modified', handleObjectModified);
canvas.off('editing:exited', handleEditingExited);
}; };
}, [onChange, onSelect, readOnly]); }, [onChange, onSelect, readOnly]);

View File

@@ -25,6 +25,7 @@ const BillingPage = React.lazy(() => import('./pages/BillingPage'));
const TasksPage = React.lazy(() => import('./pages/TasksPage')); const TasksPage = React.lazy(() => import('./pages/TasksPage'));
const TaskCollectionsPage = React.lazy(() => import('./pages/TaskCollectionsPage')); const TaskCollectionsPage = React.lazy(() => import('./pages/TaskCollectionsPage'));
const EmotionsPage = React.lazy(() => import('./pages/EmotionsPage')); const EmotionsPage = React.lazy(() => import('./pages/EmotionsPage'));
const FaqPage = React.lazy(() => import('./pages/FaqPage'));
const AuthCallbackPage = React.lazy(() => import('./pages/AuthCallbackPage')); const AuthCallbackPage = React.lazy(() => import('./pages/AuthCallbackPage'));
const WelcomeTeaserPage = React.lazy(() => import('./pages/WelcomeTeaserPage')); const WelcomeTeaserPage = React.lazy(() => import('./pages/WelcomeTeaserPage'));
const LoginStartPage = React.lazy(() => import('./pages/LoginStartPage')); const LoginStartPage = React.lazy(() => import('./pages/LoginStartPage'));
@@ -101,6 +102,7 @@ export const router = createBrowserRouter([
{ path: 'emotions', element: <EmotionsPage /> }, { path: 'emotions', element: <EmotionsPage /> },
{ path: 'billing', element: <BillingPage /> }, { path: 'billing', element: <BillingPage /> },
{ path: 'settings', element: <SettingsPage /> }, { path: 'settings', element: <SettingsPage /> },
{ path: 'faq', element: <FaqPage /> },
{ path: 'settings/profile', element: <ProfilePage /> }, { path: 'settings/profile', element: <ProfilePage /> },
{ path: 'welcome', element: <WelcomeLandingPage /> }, { path: 'welcome', element: <WelcomeLandingPage /> },
{ path: 'welcome/packages', element: <WelcomePackagesPage /> }, { path: 'welcome/packages', element: <WelcomePackagesPage /> },

View File

@@ -0,0 +1,45 @@
import type { CouponPreviewResponse } from '@/types/coupon';
function extractErrorMessage(payload: unknown): string {
if (!payload || typeof payload !== 'object') {
return 'coupon_error_generic';
}
const data = payload as Record<string, any>;
if (data.message && typeof data.message === 'string') {
return data.message;
}
if (data.errors) {
const errors = data.errors as Record<string, string[]>;
const firstKey = Object.keys(errors)[0];
if (firstKey && Array.isArray(errors[firstKey]) && errors[firstKey][0]) {
return errors[firstKey][0];
}
}
return 'coupon_error_generic';
}
export async function previewCoupon(packageId: number, code: string): Promise<CouponPreviewResponse> {
const response = await fetch('/api/v1/marketing/coupons/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
package_id: packageId,
code,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(extractErrorMessage(payload));
}
return payload as CouponPreviewResponse;
}

View File

@@ -1,20 +1,23 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, FormEvent } from 'react';
import { Head, Link, usePage } from '@inertiajs/react'; import { Head, Link, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from "@/components/ui/badge" import { Badge } from '@/components/ui/badge';
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import MarketingLayout from '@/layouts/mainWebsite'; import MarketingLayout from '@/layouts/mainWebsite';
import { useAnalytics } from '@/hooks/useAnalytics'; import { useAnalytics } from '@/hooks/useAnalytics';
import { useCtaExperiment } from '@/hooks/useCtaExperiment'; import { useCtaExperiment } from '@/hooks/useCtaExperiment';
import { ArrowRight, ShoppingCart, Check, X, Users, Image, Shield, Star, Sparkles } from 'lucide-react'; import { ArrowRight, ShoppingCart, Check, X, Users, Image, Shield, Star, Sparkles, CheckCircle2, XCircle } from 'lucide-react';
import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
import type { CouponPreviewResponse } from '@/types/coupon';
interface Package { interface Package {
id: number; id: number;
@@ -52,6 +55,10 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null); const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
const [currentStep, setCurrentStep] = useState<'overview' | 'deep' | 'testimonials'>('overview'); const [currentStep, setCurrentStep] = useState<'overview' | 'deep' | 'testimonials'>('overview');
const [couponCode, setCouponCode] = useState('');
const [couponPreview, setCouponPreview] = useState<CouponPreviewResponse | null>(null);
const [couponError, setCouponError] = useState<string | null>(null);
const [couponLoading, setCouponLoading] = useState(false);
const { props } = usePage(); const { props } = usePage();
const { auth } = props as any; const { auth } = props as any;
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
@@ -75,6 +82,19 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
} }
}, [endcustomerPackages, resellerPackages]); }, [endcustomerPackages, resellerPackages]);
useEffect(() => {
const couponParam = new URLSearchParams(window.location.search).get('coupon');
if (couponParam) {
setCouponCode(couponParam.toUpperCase());
}
}, []);
useEffect(() => {
setCouponPreview(null);
setCouponError(null);
setCouponLoading(false);
}, [selectedPackage?.id]);
const testimonials = [ const testimonials = [
{ name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 }, { name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 },
{ name: tCommon('testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 }, { name: tCommon('testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 },
@@ -125,6 +145,11 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
? isHighlightedPackage(selectedPackage, selectedVariant) ? isHighlightedPackage(selectedPackage, selectedVariant)
: false; : false;
const appliedCouponCode = couponPreview?.coupon.code ?? null;
const purchaseUrl = selectedPackage
? `/purchase-wizard/${selectedPackage.id}${appliedCouponCode ? `?coupon=${appliedCouponCode}` : ''}`
: '#';
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const handleCardClick = (pkg: Package, variant: 'endcustomer' | 'reseller') => { const handleCardClick = (pkg: Package, variant: 'endcustomer' | 'reseller') => {
@@ -148,6 +173,40 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
}); });
}; };
const handleCouponSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedPackage) {
return;
}
const trimmed = couponCode.trim();
if (!trimmed) {
setCouponPreview(null);
setCouponError(t('coupon.errors.required'));
return;
}
setCouponLoading(true);
setCouponError(null);
try {
const preview = await requestCouponPreview(selectedPackage.id, trimmed);
setCouponPreview(preview);
} catch (error) {
setCouponPreview(null);
setCouponError(error instanceof Error ? error.message : t('coupon.errors.generic'));
} finally {
setCouponLoading(false);
}
};
const handleRemoveCoupon = () => {
setCouponPreview(null);
setCouponError(null);
setCouponCode('');
};
// nextStep entfernt, da Tabs nun parallel sind // nextStep entfernt, da Tabs nun parallel sind
const getFeatureIcon = (feature: string) => { const getFeatureIcon = (feature: string) => {
@@ -795,6 +854,52 @@ function PackageCard({
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</button> </button>
)} )}
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={handleCouponSubmit}>
<Input
value={couponCode}
onChange={(event) => setCouponCode(event.target.value.toUpperCase())}
placeholder={t('coupon.placeholder')}
className="flex-1"
/>
<div className="flex gap-2">
<Button type="submit" disabled={couponLoading || !couponCode.trim()}>
{couponLoading ? t('checkout.payment_step.status_processing_title') : t('coupon.apply')}
</Button>
{couponPreview && (
<Button type="button" variant="outline" onClick={handleRemoveCoupon}>
{t('coupon.remove')}
</Button>
)}
</div>
</form>
{couponError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4" />
<span>{couponError}</span>
</div>
)}
{couponPreview && (
<div className="rounded-lg border bg-muted/20 p-3 text-sm">
<div className="flex items-center gap-2 text-emerald-600">
<CheckCircle2 className="h-4 w-4" />
<span>{t('coupon.applied', { code: couponPreview.coupon.code, amount: couponPreview.pricing.formatted.discount })}</span>
</div>
<div className="mt-2 space-y-1">
<div className="flex justify-between">
<span>{t('coupon.fields.subtotal')}</span>
<span>{couponPreview.pricing.formatted.subtotal}</span>
</div>
<div className="flex justify-between text-emerald-600">
<span>{t('coupon.fields.discount')}</span>
<span>{couponPreview.pricing.formatted.discount}</span>
</div>
<div className="flex justify-between">
<span>{t('coupon.fields.total')}</span>
<span>{couponPreview.pricing.formatted.total}</span>
</div>
</div>
</div>
)}
<Button <Button
asChild asChild
className={cn( className={cn(
@@ -804,12 +909,18 @@ function PackageCard({
)} )}
> >
<Link <Link
href={`/purchase-wizard/${selectedPackage.id}`} href={purchaseUrl}
onClick={() => { onClick={() => {
if (selectedPackage) { if (selectedPackage) {
handleCtaClick(selectedPackage, selectedVariant); handleCtaClick(selectedPackage, selectedVariant);
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
}
if (appliedCouponCode) {
localStorage.setItem('preferred_coupon_code', appliedCouponCode);
} else {
localStorage.removeItem('preferred_coupon_code');
} }
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
}} }}
> >
{t('packages.to_order')} {t('packages.to_order')}

View File

@@ -1,9 +1,13 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { LoaderCircle } from 'lucide-react'; import { LoaderCircle, CheckCircle2, XCircle } from 'lucide-react';
import { useCheckoutWizard } from '../WizardContext'; import { useCheckoutWizard } from '../WizardContext';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
import type { CouponPreviewResponse } from '@/types/coupon';
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error'; type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
@@ -108,8 +112,26 @@ export const PaymentStep: React.FC = () => {
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>('');
const [initialised, setInitialised] = useState(false); const [initialised, setInitialised] = useState(false);
const [inlineActive, setInlineActive] = useState(false); const [inlineActive, setInlineActive] = useState(false);
const [couponCode, setCouponCode] = useState<string>(() => {
if (typeof window === 'undefined') {
return '';
}
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get('coupon');
if (fromQuery) {
return fromQuery;
}
return localStorage.getItem('preferred_coupon_code') ?? '';
});
const [couponPreview, setCouponPreview] = useState<CouponPreviewResponse | null>(null);
const [couponError, setCouponError] = useState<string | null>(null);
const [couponNotice, setCouponNotice] = useState<string | null>(null);
const [couponLoading, setCouponLoading] = useState(false);
const paddleRef = useRef<typeof window.Paddle | null>(null); const paddleRef = useRef<typeof window.Paddle | null>(null);
const eventCallbackRef = useRef<(event: any) => void>(); const eventCallbackRef = useRef<(event: any) => void>();
const hasAutoAppliedCoupon = useRef(false);
const checkoutContainerClass = 'paddle-checkout-container'; const checkoutContainerClass = 'paddle-checkout-container';
const paddleLocale = useMemo(() => { const paddleLocale = useMemo(() => {
@@ -118,6 +140,84 @@ export const PaymentStep: React.FC = () => {
}, [i18n.language]); }, [i18n.language]);
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
const hasCoupon = Boolean(couponPreview);
const applyCoupon = useCallback(async (code: string) => {
if (!selectedPackage) {
return;
}
const trimmed = code.trim();
if (!trimmed) {
setCouponError(t('coupon.errors.required'));
setCouponPreview(null);
setCouponNotice(null);
return;
}
setCouponLoading(true);
setCouponError(null);
setCouponNotice(null);
try {
const preview = await requestCouponPreview(selectedPackage.id, trimmed);
setCouponPreview(preview);
setCouponNotice(
t('coupon.applied', {
code: preview.coupon.code,
amount: preview.pricing.formatted.discount,
})
);
if (typeof window !== 'undefined') {
localStorage.setItem('preferred_coupon_code', preview.coupon.code);
}
} catch (error) {
setCouponPreview(null);
setCouponNotice(null);
setCouponError(error instanceof Error ? error.message : t('coupon.errors.generic'));
} finally {
setCouponLoading(false);
}
}, [selectedPackage, t]);
useEffect(() => {
if (hasAutoAppliedCoupon.current) {
return;
}
if (couponCode && selectedPackage) {
hasAutoAppliedCoupon.current = true;
applyCoupon(couponCode);
}
}, [applyCoupon, couponCode, selectedPackage]);
useEffect(() => {
setCouponPreview(null);
setCouponNotice(null);
setCouponError(null);
hasAutoAppliedCoupon.current = false;
}, [selectedPackage?.id]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const params = new URLSearchParams(window.location.search);
const queryCoupon = params.get('coupon');
if (queryCoupon) {
const normalized = queryCoupon.toUpperCase();
setCouponCode((current) => current || normalized);
localStorage.setItem('preferred_coupon_code', normalized);
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined' && couponCode) {
localStorage.setItem('preferred_coupon_code', couponCode);
}
}, [couponCode]);
const handleFreeActivation = async () => { const handleFreeActivation = async () => {
setPaymentCompleted(true); setPaymentCompleted(true);
@@ -209,6 +309,7 @@ export const PaymentStep: React.FC = () => {
body: JSON.stringify({ body: JSON.stringify({
package_id: selectedPackage.id, package_id: selectedPackage.id,
locale: paddleLocale, locale: paddleLocale,
coupon_code: couponPreview?.coupon.code ?? undefined,
}), }),
}); });
@@ -348,6 +449,26 @@ export const PaymentStep: React.FC = () => {
setPaymentCompleted(false); setPaymentCompleted(false);
}, [selectedPackage?.id, setPaymentCompleted]); }, [selectedPackage?.id, setPaymentCompleted]);
const handleCouponSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedPackage) {
return;
}
applyCoupon(couponCode);
}, [applyCoupon, couponCode, selectedPackage]);
const handleRemoveCoupon = useCallback(() => {
setCouponPreview(null);
setCouponNotice(null);
setCouponError(null);
setCouponCode('');
if (typeof window !== 'undefined') {
localStorage.removeItem('preferred_coupon_code');
}
}, []);
if (!selectedPackage) { if (!selectedPackage) {
return ( return (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -377,6 +498,63 @@ export const PaymentStep: React.FC = () => {
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border bg-card p-6 shadow-sm"> <div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3">
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={handleCouponSubmit}>
<Input
value={couponCode}
onChange={(event) => setCouponCode(event.target.value.toUpperCase())}
placeholder={t('coupon.placeholder')}
className="flex-1"
/>
<div className="flex gap-2">
<Button type="submit" disabled={couponLoading || !couponCode.trim()}>
{couponLoading ? t('checkout.payment_step.status_processing_title') : t('coupon.apply')}
</Button>
{couponPreview && (
<Button type="button" variant="outline" onClick={handleRemoveCoupon}>
{t('coupon.remove')}
</Button>
)}
</div>
</form>
{couponError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4" />
<span>{couponError}</span>
</div>
)}
{couponNotice && (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<CheckCircle2 className="h-4 w-4" />
<span>{couponNotice}</span>
</div>
)}
{couponPreview && (
<div className="rounded-lg border bg-muted/20 p-4 text-sm">
<p className="mb-3 font-medium text-muted-foreground">{t('coupon.summary_title')}</p>
<div className="space-y-2">
<div className="flex justify-between">
<span>{t('coupon.fields.subtotal')}</span>
<span>{couponPreview.pricing.formatted.subtotal}</span>
</div>
<div className="flex justify-between text-emerald-600">
<span>{t('coupon.fields.discount')}</span>
<span>{couponPreview.pricing.formatted.discount}</span>
</div>
<div className="flex justify-between">
<span>{t('coupon.fields.tax')}</span>
<span>{couponPreview.pricing.formatted.tax}</span>
</div>
<Separator />
<div className="flex justify-between font-semibold">
<span>{t('coupon.fields.total')}</span>
<span>{couponPreview.pricing.formatted.total}</span>
</div>
</div>
</div>
)}
</div>
{!inlineActive && ( {!inlineActive && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,35 @@
export interface CouponPreviewPricing {
currency: string;
subtotal: number;
discount: number;
tax: number;
total: number;
formatted: {
subtotal: string;
discount: string;
tax: string;
total: string;
};
breakdown?: Array<Record<string, unknown>>;
}
export interface CouponPreviewResponse {
coupon: {
id: number;
code: string;
type?: string | null;
amount: number;
currency?: string | null;
description?: string | null;
expires_at?: string | null;
is_stackable?: boolean;
};
pricing: CouponPreviewPricing;
package: {
id: number;
name: string;
price: number;
currency: string;
};
source?: string;
}

View File

@@ -198,4 +198,31 @@ return [
'contact' => [ 'contact' => [
'success' => 'Danke! Wir melden uns schnellstmöglich.', 'success' => 'Danke! Wir melden uns schnellstmöglich.',
], ],
'coupon' => [
'label' => 'Gutscheincode',
'placeholder' => 'Gutscheincode eingeben',
'apply' => 'Gutschein anwenden',
'remove' => 'Gutschein entfernen',
'applied' => 'Gutschein :code aktiviert. Du sparst :amount.',
'summary_title' => 'Aktualisierte Bestellsumme',
'fields' => [
'subtotal' => 'Zwischensumme',
'discount' => 'Rabatt',
'tax' => 'MwSt.',
'total' => 'Gesamtsumme nach Rabatt',
],
'errors' => [
'required' => 'Bitte gib einen Gutscheincode ein.',
'not_found' => 'Dieser Gutschein konnte nicht gefunden werden.',
'inactive' => 'Dieser Gutschein ist nicht aktiv.',
'disabled' => 'Dieser Gutschein kann derzeit nicht eingelöst werden.',
'not_applicable' => 'Dieser Gutschein gilt nicht für das ausgewählte Package.',
'limit_reached' => 'Dieser Gutschein wurde bereits maximal genutzt.',
'currency_mismatch' => 'Dieser Gutschein passt nicht zur gewählten Währung.',
'not_synced' => 'Dieser Gutschein ist noch nicht bereit. Bitte versuche es später erneut.',
'package_not_configured' => 'Dieses Package unterstützt aktuell keine Gutscheine.',
'login_required' => 'Bitte melde dich an, um diesen Gutschein zu nutzen.',
'generic' => 'Der Gutschein konnte nicht angewendet werden. Bitte versuche einen anderen.',
],
],
]; ];

View File

@@ -198,4 +198,31 @@ return [
'contact' => [ 'contact' => [
'success' => 'Thanks! We will get back to you soon.', 'success' => 'Thanks! We will get back to you soon.',
], ],
'coupon' => [
'label' => 'Coupon code',
'placeholder' => 'Enter your coupon code',
'apply' => 'Apply coupon',
'remove' => 'Remove coupon',
'applied' => 'Coupon :code applied. You save :amount.',
'summary_title' => 'Updated order summary',
'fields' => [
'subtotal' => 'Subtotal',
'discount' => 'Discount',
'tax' => 'Tax',
'total' => 'Total after discount',
],
'errors' => [
'required' => 'Please enter a coupon code.',
'not_found' => 'We could not find this coupon.',
'inactive' => 'This coupon is not active anymore.',
'disabled' => 'This coupon cannot be used at checkout.',
'not_applicable' => 'This coupon is not valid for the selected package.',
'limit_reached' => 'This coupon was already used the maximum number of times.',
'currency_mismatch' => 'This coupon cannot be used with the selected currency.',
'not_synced' => 'This coupon is not ready yet. Please try again later.',
'package_not_configured' => 'This package is not available for coupon redemptions.',
'login_required' => 'Please log in to use this coupon.',
'generic' => 'We could not apply this coupon. Please try another one.',
],
],
]; ];

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Api\EventPublicController; use App\Http\Controllers\Api\EventPublicController;
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\PackageController;
use App\Http\Controllers\Api\Tenant\DashboardController; use App\Http\Controllers\Api\Tenant\DashboardController;
use App\Http\Controllers\Api\Tenant\EmotionController; use App\Http\Controllers\Api\Tenant\EmotionController;
@@ -27,6 +28,12 @@ use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1')->name('api.v1.')->group(function () { Route::prefix('v1')->name('api.v1.')->group(function () {
Route::prefix('marketing')->name('marketing.')->group(function () {
Route::post('/coupons/preview', CouponPreviewController::class)
->middleware('throttle:coupon-preview')
->name('coupons.preview');
});
Route::post('/webhooks/revenuecat', [RevenueCatWebhookController::class, 'handle']) Route::post('/webhooks/revenuecat', [RevenueCatWebhookController::class, 'handle'])
->middleware('throttle:60,1') ->middleware('throttle:60,1')
->name('webhooks.revenuecat'); ->name('webhooks.revenuecat');

View File

@@ -0,0 +1,72 @@
<?php
namespace Tests\Feature\Api\Marketing;
use App\Models\Coupon;
use App\Models\Package;
use App\Services\Paddle\PaddleDiscountService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class CouponPreviewTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_guest_can_preview_coupon(): void
{
$package = Package::factory()->create([
'paddle_price_id' => 'pri_test',
'price' => 100,
]);
$coupon = Coupon::factory()->create([
'code' => 'SAVE20',
'paddle_discount_id' => 'dsc_123',
'per_customer_limit' => null,
]);
$coupon->packages()->attach($package);
$this->instance(PaddleDiscountService::class, Mockery::mock(PaddleDiscountService::class, function ($mock) {
$mock->shouldReceive('previewDiscount')->andReturn([
'totals' => [
'currency_code' => 'EUR',
'subtotal' => 10000,
'discount' => 2000,
'tax' => 0,
'total' => 8000,
],
]);
}));
$response = $this->postJson(route('api.v1.marketing.coupons.preview'), [
'package_id' => $package->id,
'code' => 'save20',
]);
$response->assertOk()
->assertJsonPath('coupon.code', 'SAVE20');
$this->assertEquals(80.0, (float) $response->json('pricing.total'));
}
public function test_invalid_coupon_returns_validation_error(): void
{
$package = Package::factory()->create([
'paddle_price_id' => 'pri_test_invalid',
]);
$this->postJson(route('api.v1.marketing.coupons.preview'), [
'package_id' => $package->id,
'code' => 'UNKNOWN',
])->assertUnprocessable()
->assertJsonValidationErrors('code');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Console;
use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\Package;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CouponExportCommandTest extends TestCase
{
use RefreshDatabase;
public function test_export_command_creates_csv(): void
{
Storage::fake('local');
$coupon = Coupon::factory()->create([
'code' => 'FLASH20',
]);
$package = Package::factory()->create();
$tenant = Tenant::factory()->create();
CouponRedemption::factory()->create([
'coupon_id' => $coupon->id,
'package_id' => $package->id,
'tenant_id' => $tenant->id,
'status' => CouponRedemption::STATUS_SUCCESS,
'amount_discounted' => 25,
'redeemed_at' => now(),
]);
$path = 'reports/test-coupons.csv';
$this->artisan('coupons:export', [
'--days' => 7,
'--path' => $path,
])->assertExitCode(0);
Storage::disk('local')->assertExists($path);
$contents = Storage::disk('local')->get($path);
$this->assertStringContainsString('FLASH20', $contents);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Tests\Feature;
use App\Models\Coupon;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Coupons\CouponService;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class PaddleCheckoutControllerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_authenticated_user_can_create_checkout_with_coupon(): void
{
$tenant = Tenant::factory()->create([
'paddle_customer_id' => 'cus_123',
]);
$user = User::factory()->for($tenant)->create();
$package = Package::factory()->create([
'paddle_price_id' => 'pri_123',
'paddle_product_id' => 'pro_123',
'price' => 120,
]);
$coupon = Coupon::factory()->create([
'code' => 'SAVE15',
'paddle_discount_id' => 'dsc_123',
]);
$coupon->packages()->attach($package);
$couponServiceMock = Mockery::mock(CouponService::class);
$couponServiceMock->shouldReceive('preview')
->once()
->andReturn([
'coupon' => $coupon,
'pricing' => [
'currency' => 'EUR',
'subtotal' => 120.0,
'discount' => 18.0,
'tax' => 0,
'total' => 102.0,
'formatted' => [
'subtotal' => '€120.00',
'discount' => '-€18.00',
'tax' => '€0.00',
'total' => '€102.00',
],
'breakdown' => [],
],
'source' => 'manual',
]);
$this->instance(CouponService::class, $couponServiceMock);
$paddleServiceMock = Mockery::mock(PaddleCheckoutService::class);
$paddleServiceMock->shouldReceive('createCheckout')
->once()
->andReturn([
'checkout_url' => 'https://example.com/checkout/test',
'id' => 'chk_123',
]);
$this->instance(PaddleCheckoutService::class, $paddleServiceMock);
$this->be($user);
$response = $this->postJson(route('paddle.checkout.create'), [
'package_id' => $package->id,
'coupon_code' => 'SAVE15',
]);
$response->assertOk()
->assertJsonPath('checkout_url', 'https://example.com/checkout/test');
$this->assertDatabaseHas('checkout_sessions', [
'package_id' => $package->id,
'coupon_code' => 'SAVE15',
]);
}
}

64
tests/Unit/CouponTest.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
namespace Tests\Unit;
use App\Models\Coupon;
use App\Models\Package;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CouponTest extends TestCase
{
use RefreshDatabase;
public function test_it_evaluates_active_state(): void
{
$coupon = Coupon::factory()->create([
'starts_at' => now()->subDay(),
'ends_at' => now()->addDay(),
'usage_limit' => 1,
'redemptions_count' => 0,
]);
$this->assertTrue($coupon->isCurrentlyActive());
$coupon->update(['redemptions_count' => 1]);
$this->assertFalse($coupon->refresh()->isCurrentlyActive());
$coupon->update([
'usage_limit' => null,
'starts_at' => now()->addDay(),
'redemptions_count' => 0,
]);
$this->assertFalse($coupon->fresh()->isCurrentlyActive());
}
public function test_it_checks_package_applicability(): void
{
$coupon = Coupon::factory()->create();
$packageA = Package::factory()->create();
$packageB = Package::factory()->create();
$this->assertTrue($coupon->appliesToPackage($packageA));
$coupon->packages()->sync([$packageA->getKey()]);
$this->assertTrue($coupon->fresh()->appliesToPackage($packageA));
$this->assertFalse($coupon->appliesToPackage($packageB));
}
public function test_remaining_usage_calculation(): void
{
$coupon = Coupon::factory()->create([
'usage_limit' => 10,
'per_customer_limit' => 2,
'redemptions_count' => 4,
]);
$this->assertSame(6, $coupon->remainingUsages());
$this->assertSame(2, $coupon->remainingUsages(0));
$this->assertSame(1, $coupon->remainingUsages(1));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Unit;
use App\Models\Tenant;
use App\Support\TenantRequestResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Tests\TestCase;
class TenantRequestResolverTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_tenant_from_request_attribute(): void
{
$tenant = Tenant::factory()->make();
$request = Request::create('/api/tenant/test', 'GET');
$request->attributes->set('tenant', $tenant);
$resolved = TenantRequestResolver::resolve($request);
$this->assertSame($tenant, $resolved);
}
public function test_it_finds_tenant_using_identifier(): void
{
$tenant = Tenant::factory()->create();
$request = Request::create('/api/tenant/test', 'GET');
$request->attributes->set('tenant_id', $tenant->id);
$resolved = TenantRequestResolver::resolve($request);
$this->assertTrue($tenant->is($resolved));
}
public function test_it_throws_when_tenant_cannot_be_resolved(): void
{
$this->expectException(HttpResponseException::class);
$request = Request::create('/api/tenant/test', 'GET');
TenantRequestResolver::resolve($request);
}
}