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:
56
app/Console/Commands/ExportCouponRedemptions.php
Normal file
56
app/Console/Commands/ExportCouponRedemptions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
23
app/Enums/CouponStatus.php
Normal file
23
app/Enums/CouponStatus.php
Normal 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
19
app/Enums/CouponType.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
123
app/Filament/Resources/Coupons/CouponResource.php
Normal file
123
app/Filament/Resources/Coupons/CouponResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
17
app/Filament/Resources/Coupons/Pages/CreateCoupon.php
Normal file
17
app/Filament/Resources/Coupons/Pages/CreateCoupon.php
Normal 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);
|
||||
}
|
||||
}
|
||||
32
app/Filament/Resources/Coupons/Pages/EditCoupon.php
Normal file
32
app/Filament/Resources/Coupons/Pages/EditCoupon.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Coupons/Pages/ListCoupons.php
Normal file
19
app/Filament/Resources/Coupons/Pages/ListCoupons.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Coupons/Pages/ViewCoupon.php
Normal file
19
app/Filament/Resources/Coupons/Pages/ViewCoupon.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
148
app/Filament/Resources/Coupons/Schemas/CouponForm.php
Normal file
148
app/Filament/Resources/Coupons/Schemas/CouponForm.php
Normal 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)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
app/Filament/Resources/Coupons/Schemas/CouponInfolist.php
Normal file
81
app/Filament/Resources/Coupons/Schemas/CouponInfolist.php
Normal 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'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
113
app/Filament/Resources/Coupons/Tables/CouponsTable.php
Normal file
113
app/Filament/Resources/Coupons/Tables/CouponsTable.php
Normal 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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
60
app/Filament/Widgets/CouponUsageWidget.php
Normal file
60
app/Filament/Widgets/CouponUsageWidget.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Http\Requests\Tenant\EmotionStoreRequest;
|
||||
use App\Http\Requests\Tenant\EmotionUpdateRequest;
|
||||
use App\Http\Resources\Tenant\EmotionResource;
|
||||
use App\Models\Emotion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -16,7 +18,7 @@ class EmotionController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->tenant->id;
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$query = Emotion::query()
|
||||
->whereNull('tenant_id')
|
||||
@@ -41,9 +43,10 @@ class EmotionController extends Controller
|
||||
public function store(EmotionStoreRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$payload = [
|
||||
'tenant_id' => $request->tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $this->localizeValue($data['name']),
|
||||
'description' => $this->localizeValue($data['description'] ?? null, allowNull: true),
|
||||
'icon' => $data['icon'] ?? 'lucide-smile',
|
||||
@@ -70,7 +73,9 @@ class EmotionController extends Controller
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -139,6 +144,7 @@ class EmotionController extends Controller
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$locale = app()->getLocale() ?: 'de';
|
||||
|
||||
return [$locale => $value];
|
||||
}
|
||||
|
||||
@@ -149,9 +155,14 @@ class EmotionController extends Controller
|
||||
{
|
||||
$normalized = ltrim($color, '#');
|
||||
if (strlen($normalized) === 6) {
|
||||
return '#' . strtolower($normalized);
|
||||
return '#'.strtolower($normalized);
|
||||
}
|
||||
|
||||
return '#6366f1';
|
||||
}
|
||||
|
||||
protected function currentTenant(Request $request): Tenant
|
||||
{
|
||||
return TenantRequestResolver::resolve($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ class SettingsController extends Controller
|
||||
}
|
||||
|
||||
$taken = Tenant::where('custom_domain', $domain)
|
||||
->where('id', '!=', $request->tenant->id)
|
||||
->where('id', '!=', $this->resolveTenant($request)->id)
|
||||
->exists();
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -6,17 +6,19 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Tenant\TaskCollectionResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Tenant\TaskCollectionImportService;
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TaskCollectionController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->tenant->id;
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$query = TaskCollection::query()
|
||||
->forTenant($tenantId)
|
||||
@@ -68,11 +70,11 @@ class TaskCollectionController extends Controller
|
||||
$this->authorizeAccess($request, $collection);
|
||||
|
||||
$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'])
|
||||
->where('tenant_id', $request->tenant->id)
|
||||
->where('tenant_id', $this->currentTenant($request)->id)
|
||||
->firstOrFail();
|
||||
|
||||
$result = $importService->import($collection, $event);
|
||||
@@ -87,8 +89,13 @@ class TaskCollectionController extends Controller
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
protected function currentTenant(Request $request): Tenant
|
||||
{
|
||||
return TenantRequestResolver::resolve($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ use App\Http\Resources\Tenant\TaskResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Task;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -22,14 +24,14 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->tenant->id;
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$query = Task::query()
|
||||
->where(function ($inner) use ($tenantId) {
|
||||
$inner->whereNull('tenant_id')
|
||||
->orWhere('tenant_id', $tenantId);
|
||||
})
|
||||
->with(['taskCollection', 'assignedEvents'])
|
||||
->with(['taskCollection', 'assignedEvents', 'eventType'])
|
||||
->orderByRaw('tenant_id is null desc')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('created_at', 'desc');
|
||||
@@ -64,11 +66,12 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function store(TaskStoreRequest $request): JsonResponse
|
||||
{
|
||||
$tenant = $this->currentTenant($request);
|
||||
$collectionId = $request->input('collection_id');
|
||||
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
||||
|
||||
$payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id);
|
||||
$payload['tenant_id'] = $request->tenant->id;
|
||||
$payload = $this->prepareTaskPayload($request->validated(), $tenant->id);
|
||||
$payload['tenant_id'] = $tenant->id;
|
||||
|
||||
if ($collection) {
|
||||
$payload['collection_id'] = $collection->id;
|
||||
@@ -77,7 +80,7 @@ class TaskController extends Controller
|
||||
|
||||
$task = Task::create($payload);
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents']);
|
||||
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Task erfolgreich erstellt.',
|
||||
@@ -90,11 +93,11 @@ class TaskController extends Controller
|
||||
*/
|
||||
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.');
|
||||
}
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents']);
|
||||
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
|
||||
|
||||
return response()->json(new TaskResource($task));
|
||||
}
|
||||
@@ -104,14 +107,16 @@ class TaskController extends Controller
|
||||
*/
|
||||
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.');
|
||||
}
|
||||
|
||||
$collectionId = $request->input('collection_id');
|
||||
$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) {
|
||||
$payload['collection_id'] = $collection->id;
|
||||
@@ -133,7 +138,7 @@ class TaskController extends Controller
|
||||
*/
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -149,7 +154,9 @@ class TaskController extends Controller
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -169,7 +176,9 @@ class TaskController extends Controller
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -184,7 +193,7 @@ class TaskController extends Controller
|
||||
}
|
||||
|
||||
$tasks = Task::whereIn('id', $taskIds)
|
||||
->where('tenant_id', $request->tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->get();
|
||||
|
||||
$attached = 0;
|
||||
@@ -205,12 +214,12 @@ class TaskController extends Controller
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
|
||||
->with(['taskCollection'])
|
||||
->with(['taskCollection', 'eventType'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
@@ -222,12 +231,12 @@ class TaskController extends Controller
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
$tasks = $collection->tasks()
|
||||
->with(['assignedEvents'])
|
||||
->with(['assignedEvents', 'eventType'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
@@ -240,13 +249,20 @@ class TaskController extends Controller
|
||||
->where(function ($query) use ($request) {
|
||||
$query->whereNull('tenant_id');
|
||||
|
||||
if ($request->tenant?->id) {
|
||||
$query->orWhere('tenant_id', $request->tenant->id);
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($tenantId) {
|
||||
$query->orWhere('tenant_id', $tenantId);
|
||||
}
|
||||
})
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
protected function currentTenant(Request $request): Tenant
|
||||
{
|
||||
return TenantRequestResolver::resolve($request);
|
||||
}
|
||||
|
||||
protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
|
||||
{
|
||||
if (array_key_exists('title', $data)) {
|
||||
|
||||
@@ -9,12 +9,14 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
@@ -32,6 +34,7 @@ class MarketingController extends Controller
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $checkoutSessions,
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
@@ -107,8 +110,10 @@ class MarketingController extends Controller
|
||||
Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]);
|
||||
$package = Package::findOrFail($packageId);
|
||||
|
||||
$couponCode = $this->rememberCouponFromRequest($request, $package);
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
@@ -167,6 +172,19 @@ class MarketingController extends Controller
|
||||
|
||||
$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, [
|
||||
'success_url' => route('marketing.success', [
|
||||
'locale' => app()->getLocale(),
|
||||
@@ -178,7 +196,9 @@ class MarketingController extends Controller
|
||||
]),
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'coupon_code' => $couponCode,
|
||||
],
|
||||
'discount_id' => $appliedDiscountId,
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
@@ -210,6 +230,34 @@ class MarketingController extends Controller
|
||||
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)
|
||||
{
|
||||
$locale = $request->get('locale', app()->getLocale());
|
||||
|
||||
@@ -5,10 +5,12 @@ namespace App\Http\Controllers;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PaddleCheckoutController extends Controller
|
||||
@@ -16,6 +18,7 @@ class PaddleCheckoutController extends Controller
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $checkout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): JsonResponse
|
||||
@@ -25,6 +28,7 @@ class PaddleCheckoutController extends Controller
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
@@ -46,7 +50,16 @@ class PaddleCheckoutController extends Controller
|
||||
|
||||
$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 ?? [], [
|
||||
'mode' => 'inline',
|
||||
]);
|
||||
@@ -80,7 +93,9 @@ class PaddleCheckoutController extends Controller
|
||||
'return_url' => $data['return_url'] ?? null,
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'coupon_code' => $couponCode ?: null,
|
||||
],
|
||||
'discount_id' => $discountId,
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -22,12 +23,12 @@ class TaskStoreRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = TenantRequestResolver::resolve($this)->id;
|
||||
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
|
||||
'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) use ($tenantId) {
|
||||
$accessible = \App\Models\TaskCollection::where('id', $value)
|
||||
->where(function ($query) use ($tenantId) {
|
||||
$query->whereNull('tenant_id');
|
||||
@@ -45,9 +46,8 @@ class TaskStoreRequest extends FormRequest
|
||||
'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
|
||||
'due_date' => ['nullable', 'date', 'after:now'],
|
||||
'is_completed' => ['nullable', 'boolean'],
|
||||
'assigned_to' => ['nullable', 'exists:users,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
'assigned_to' => ['nullable', 'exists:users,id', function ($attribute, $value, $fail) use ($tenantId) {
|
||||
if ($tenantId && ! \App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -22,12 +23,12 @@ class TaskUpdateRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = TenantRequestResolver::resolve($this)->id;
|
||||
|
||||
return [
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'nullable', 'string'],
|
||||
'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
|
||||
'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) use ($tenantId) {
|
||||
$accessible = \App\Models\TaskCollection::where('id', $value)
|
||||
->where(function ($query) use ($tenantId) {
|
||||
$query->whereNull('tenant_id');
|
||||
@@ -45,9 +46,8 @@ class TaskUpdateRequest extends FormRequest
|
||||
'priority' => ['sometimes', 'nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
|
||||
'due_date' => ['sometimes', 'nullable', 'date'],
|
||||
'is_completed' => ['sometimes', 'boolean'],
|
||||
'assigned_to' => ['sometimes', 'nullable', 'exists:users,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
'assigned_to' => ['sometimes', 'nullable', 'exists:users,id', function ($attribute, $value, $fail) use ($tenantId) {
|
||||
if ($tenantId && ! \App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}],
|
||||
|
||||
@@ -36,6 +36,11 @@ class TaskResource extends JsonResource
|
||||
'difficulty' => $this->difficulty,
|
||||
'due_date' => $this->due_date?->toISOString(),
|
||||
'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,
|
||||
'source_task_id' => $this->source_task_id,
|
||||
'source_collection_id' => $this->source_collection_id,
|
||||
|
||||
68
app/Jobs/SyncCouponToPaddle.php
Normal file
68
app/Jobs/SyncCouponToPaddle.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,13 @@ class CheckoutSession extends Model
|
||||
'package_snapshot' => 'array',
|
||||
'status_history' => 'array',
|
||||
'provider_metadata' => 'array',
|
||||
'coupon_snapshot' => 'array',
|
||||
'discount_breakdown' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'amount_subtotal' => 'decimal:2',
|
||||
'amount_total' => 'decimal:2',
|
||||
'amount_discount' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -71,6 +74,8 @@ class CheckoutSession extends Model
|
||||
'status_history' => '[]',
|
||||
'package_snapshot' => '[]',
|
||||
'provider_metadata' => '[]',
|
||||
'coupon_snapshot' => '[]',
|
||||
'discount_breakdown' => '[]',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
@@ -88,6 +93,11 @@ class CheckoutSession extends Model
|
||||
return $this->belongsTo(Package::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function coupon(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Coupon::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereNotIn('status', [
|
||||
|
||||
189
app/Models/Coupon.php
Normal file
189
app/Models/Coupon.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
84
app/Models/CouponRedemption.php
Normal file
84
app/Models/CouponRedemption.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -96,6 +97,11 @@ class Package extends Model
|
||||
return $this->hasMany(PackagePurchase::class);
|
||||
}
|
||||
|
||||
public function coupons(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Coupon::class)->withTimestamps();
|
||||
}
|
||||
|
||||
public function isEndcustomer(): bool
|
||||
{
|
||||
return $this->type === 'endcustomer';
|
||||
|
||||
@@ -132,6 +132,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
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('analytics', static function () {
|
||||
$config = config('services.matomo');
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
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\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
@@ -10,7 +18,6 @@ use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets;
|
||||
use App\Filament\Resources\LegalPageResource;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
@@ -18,14 +25,6 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
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
|
||||
{
|
||||
@@ -56,6 +55,7 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
CreditAlertsWidget::class,
|
||||
RevenueTrendWidget::class,
|
||||
PlatformStatsWidget::class,
|
||||
\App\Filament\Widgets\CouponUsageWidget::class,
|
||||
TopTenantsByRevenue::class,
|
||||
TopTenantsByUploads::class,
|
||||
\App\Filament\Widgets\StorageCapacityWidget::class,
|
||||
@@ -85,10 +85,9 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
CategoryResource::class,
|
||||
LegalPageResource::class,
|
||||
])
|
||||
->authGuard('web')
|
||||
->authGuard('web');
|
||||
|
||||
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
|
||||
// Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -64,6 +65,7 @@ class CheckoutSessionService
|
||||
$session->package_snapshot = $this->packageSnapshot($package);
|
||||
$session->amount_subtotal = 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->status = CheckoutSession::STATUS_DRAFT;
|
||||
$session->stripe_payment_intent_id = null;
|
||||
@@ -73,6 +75,10 @@ class CheckoutSessionService
|
||||
$session->paddle_transaction_id = null;
|
||||
$session->provider_metadata = [];
|
||||
$session->failure_reason = null;
|
||||
$session->coupon()->dissociate();
|
||||
$session->coupon_code = null;
|
||||
$session->coupon_snapshot = [];
|
||||
$session->discount_breakdown = [];
|
||||
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
|
||||
$this->appendStatus($session, CheckoutSession::STATUS_DRAFT, 'package_switched');
|
||||
$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
|
||||
{
|
||||
$provider = strtolower($provider);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\Paddle\PaddleSubscriptionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -19,6 +20,7 @@ class CheckoutWebhookService
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
private readonly PaddleSubscriptionService $paddleSubscriptions,
|
||||
private readonly CouponRedemptionService $couponRedemptions,
|
||||
) {}
|
||||
|
||||
public function handleStripeEvent(array $event): bool
|
||||
@@ -216,6 +218,7 @@ class CheckoutWebhookService
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
$this->couponRedemptions->recordSuccess($session, $data);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -224,6 +227,7 @@ class CheckoutWebhookService
|
||||
case 'transaction.cancelled':
|
||||
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
|
||||
$this->sessions->markFailed($session, $reason);
|
||||
$this->couponRedemptions->recordFailure($session, $reason);
|
||||
|
||||
return true;
|
||||
|
||||
|
||||
72
app/Services/Coupons/CouponRedemptionService.php
Normal file
72
app/Services/Coupons/CouponRedemptionService.php
Normal 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,
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
287
app/Services/Coupons/CouponService.php
Normal file
287
app/Services/Coupons/CouponService.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -46,6 +46,10 @@ class PaddleCheckoutService
|
||||
'cancel_url' => $returnUrl,
|
||||
];
|
||||
|
||||
if (! empty($options['discount_id'])) {
|
||||
$payload['discount_id'] = $options['discount_id'];
|
||||
}
|
||||
|
||||
if ($tenant->contact_email) {
|
||||
$payload['customer_email'] = $tenant->contact_email;
|
||||
}
|
||||
|
||||
149
app/Services/Paddle/PaddleDiscountService.php
Normal file
149
app/Services/Paddle/PaddleDiscountService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -28,17 +28,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#B85C76',
|
||||
'secondary' => '#E7D6DC',
|
||||
'badge' => '#7A9375',
|
||||
'badge_label' => 'Unsere Gästegalerie',
|
||||
'instructions_heading' => 'So seid ihr dabei',
|
||||
'link_heading' => 'Falls der Scan nicht klappt',
|
||||
'cta_label' => 'Gästegalerie öffnen',
|
||||
'cta_caption' => 'Jetzt Erinnerungen sammeln',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Fotos & Grüße teilen',
|
||||
'cta_caption' => 'Sofort starten',
|
||||
'qr' => ['size_px' => 640],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und mit eurem Lieblingsnamen anmelden.',
|
||||
'Ein paar Schnappschüsse teilen – gern auch Behind-the-Scenes!',
|
||||
'Likes vergeben und Grüße für das Brautpaar schreiben.',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'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' => [
|
||||
@@ -57,17 +60,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#F9C74F',
|
||||
'secondary' => '#4E5D8F',
|
||||
'badge' => '#F94144',
|
||||
'badge_label' => 'Team Lounge Access',
|
||||
'instructions_heading' => 'In drei Schritten bereit',
|
||||
'link_heading' => 'Link teilen statt scannen',
|
||||
'cta_label' => 'Jetzt Event-Hub öffnen',
|
||||
'cta_caption' => 'Programm, Uploads & Highlights',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Scan & losknipsen',
|
||||
'cta_caption' => 'Keine App nötig',
|
||||
'qr' => ['size_px' => 640],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen oder Kurzlink eingeben.',
|
||||
'Mit Firmen-E-Mail anmelden und Zugang bestätigen.',
|
||||
'Agenda verfolgen, Fotos teilen und Highlights voten.',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'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' => [
|
||||
@@ -86,17 +92,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#6BAA75',
|
||||
'secondary' => '#DDE9D8',
|
||||
'badge' => '#F1C376',
|
||||
'badge_label' => 'Brunch Fotostation',
|
||||
'instructions_heading' => 'So funktioniert’s',
|
||||
'link_heading' => 'Alternativ zum Scannen',
|
||||
'cta_label' => 'Gästebuch öffnen',
|
||||
'cta_caption' => 'Eure Grüße festhalten',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Jetzt Erinnerungen hochladen',
|
||||
'cta_caption' => 'Los geht’s',
|
||||
'qr' => ['size_px' => 660],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und Namen eintragen.',
|
||||
'Lieblingsfoto hochladen oder neue Momente festhalten.',
|
||||
'Aufgaben ausprobieren und anderen ein Herz dalassen.',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'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' => [
|
||||
@@ -115,17 +124,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#F9A826',
|
||||
'secondary' => '#DDB7FF',
|
||||
'badge' => '#FF6F61',
|
||||
'badge_label' => 'Night Shots',
|
||||
'instructions_heading' => 'Step-by-Step',
|
||||
'link_heading' => 'QR funktioniert nicht?',
|
||||
'cta_label' => 'Partyfeed starten',
|
||||
'cta_caption' => 'Momente live teilen',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Galerie öffnen',
|
||||
'cta_caption' => 'Challenges spielen',
|
||||
'qr' => ['size_px' => 680],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'Code scannen und kurz registrieren.',
|
||||
'Spotlights & Challenges entdecken.',
|
||||
'Fotos hochladen und die besten Shots voten.',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'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' => [
|
||||
@@ -144,17 +156,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#FF6F61',
|
||||
'secondary' => '#F9D6A5',
|
||||
'badge' => '#4E88FF',
|
||||
'badge_label' => 'Party-Schnappschüsse',
|
||||
'instructions_heading' => 'Leg direkt los',
|
||||
'link_heading' => 'Kurzlink für Gäste',
|
||||
'cta_label' => 'Zur Geburtstagswand',
|
||||
'cta_caption' => 'Fotos & Grüße posten',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Uploads beginnen',
|
||||
'cta_caption' => 'Likes vergeben',
|
||||
'qr' => ['size_px' => 680],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und Wunschname auswählen.',
|
||||
'Dein erstes Foto oder Video hochladen.',
|
||||
'Freunde einladen, Likes vergeben und gemeinsam feiern!',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
|
||||
'Highlights liken, Kommentare und Grüße dalassen.',
|
||||
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
41
app/Support/TenantRequestResolver.php
Normal file
41
app/Support/TenantRequestResolver.php
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
)
|
||||
->withCommands([
|
||||
\App\Console\Commands\CheckEventPackages::class,
|
||||
\App\Console\Commands\ExportCouponRedemptions::class,
|
||||
])
|
||||
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
|
||||
$schedule->command('package:check-status')->dailyAt('06:00');
|
||||
|
||||
51
database/factories/CouponFactory.php
Normal file
51
database/factories/CouponFactory.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
35
database/factories/CouponRedemptionFactory.php
Normal file
35
database/factories/CouponRedemptionFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
12
docs/changes/2025-11-08-coupon-ops.md
Normal file
12
docs/changes/2025-11-08-coupon-ops.md
Normal 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.
|
||||
@@ -246,6 +246,8 @@ export type TenantTask = {
|
||||
difficulty: 'easy' | 'medium' | 'hard' | null;
|
||||
due_date: string | null;
|
||||
is_completed: boolean;
|
||||
event_type_id: number | null;
|
||||
event_type?: TenantEventType | null;
|
||||
tenant_id: number | null;
|
||||
collection_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 descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
|
||||
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 {
|
||||
id: Number(task.id ?? 0),
|
||||
@@ -709,6 +716,8 @@ function normalizeTask(task: JsonValue): TenantTask {
|
||||
difficulty: (task.difficulty ?? null) as TenantTask['difficulty'],
|
||||
due_date: task.due_date ?? null,
|
||||
is_completed: Boolean(task.is_completed ?? false),
|
||||
event_type_id: eventTypeId,
|
||||
event_type: eventType,
|
||||
tenant_id: task.tenant_id ?? null,
|
||||
collection_id: task.collection_id ?? null,
|
||||
source_task_id: task.source_task_id ?? null,
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
CalendarDays,
|
||||
Sparkles,
|
||||
CreditCard,
|
||||
Settings as SettingsIcon,
|
||||
} from 'lucide-react';
|
||||
import { LayoutDashboard, CalendarDays, Camera, Settings } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_ENGAGEMENT_PATH,
|
||||
} from '../constants';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { registerApiErrorListener } from '../lib/apiError';
|
||||
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 = [
|
||||
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', icon: LayoutDashboard, end: true },
|
||||
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events', icon: CalendarDays },
|
||||
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement', icon: Sparkles },
|
||||
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing', icon: CreditCard },
|
||||
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings', icon: SettingsIcon },
|
||||
];
|
||||
type NavItem = {
|
||||
key: string;
|
||||
to: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
end?: boolean;
|
||||
highlight?: boolean;
|
||||
prefetchKey?: string;
|
||||
};
|
||||
|
||||
interface AdminLayoutProps {
|
||||
title: string;
|
||||
@@ -39,6 +39,51 @@ interface AdminLayoutProps {
|
||||
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
|
||||
const { t } = useTranslation('common');
|
||||
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(() => ({
|
||||
[ADMIN_HOME_PATH]: () =>
|
||||
@@ -48,7 +93,6 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
getTenantPackagesOverview(),
|
||||
]).then(() => undefined),
|
||||
[ADMIN_EVENTS_PATH]: () => getEvents().then(() => undefined),
|
||||
[ADMIN_ENGAGEMENT_PATH]: () => getEvents().then(() => undefined),
|
||||
[ADMIN_BILLING_PATH]: () => getTenantPackagesOverview().then(() => undefined),
|
||||
[ADMIN_SETTINGS_PATH]: () => Promise.resolve(),
|
||||
}), []);
|
||||
@@ -109,35 +153,43 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EventSwitcher />
|
||||
{actions}
|
||||
<LanguageSwitcher />
|
||||
<NotificationCenter />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{navItems.map(({ to, labelKey, icon: Icon, end }) => (
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onPointerEnter={() => triggerPrefetch(to)}
|
||||
onFocus={() => triggerPrefetch(to)}
|
||||
onTouchStart={() => triggerPrefetch(to)}
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onPointerEnter={() => triggerPrefetch(item.prefetchKey ?? item.to)}
|
||||
onFocus={() => triggerPrefetch(item.prefetchKey ?? item.to)}
|
||||
onTouchStart={() => triggerPrefetch(item.prefetchKey ?? item.to)}
|
||||
className={({ isActive }) =>
|
||||
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',
|
||||
isActive
|
||||
? '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" />
|
||||
{t(labelKey)}
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<EventMenuBar />
|
||||
</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">
|
||||
@@ -154,7 +206,7 @@ function TenantMobileNav({
|
||||
items,
|
||||
onPrefetch,
|
||||
}: {
|
||||
items: typeof navItems;
|
||||
items: NavItem[];
|
||||
onPrefetch: (path: string) => void;
|
||||
}) {
|
||||
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="mx-auto flex max-w-xl items-center justify-around gap-1">
|
||||
{items.map(({ to, labelKey, icon: Icon, end }) => (
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onPointerEnter={() => onPrefetch(to)}
|
||||
onFocus={() => onPrefetch(to)}
|
||||
onTouchStart={() => onPrefetch(to)}
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onPointerEnter={() => onPrefetch(item.prefetchKey ?? item.to)}
|
||||
onFocus={() => onPrefetch(item.prefetchKey ?? item.to)}
|
||||
onTouchStart={() => onPrefetch(item.prefetchKey ?? item.to)}
|
||||
className={({ isActive }) =>
|
||||
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',
|
||||
isActive
|
||||
? '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" />
|
||||
<span>{t(labelKey)}</span>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
225
resources/js/admin/components/EventNav.tsx
Normal file
225
resources/js/admin/components/EventNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -10,42 +10,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import i18n from '../i18n';
|
||||
|
||||
type SupportedLocale = 'de' | 'en';
|
||||
|
||||
const SUPPORTED_LANGUAGES: Array<{ code: SupportedLocale; labelKey: string }> = [
|
||||
{ code: 'de', labelKey: 'language.de' },
|
||||
{ code: 'en', labelKey: 'language.en' },
|
||||
];
|
||||
|
||||
function getCsrfToken(): string {
|
||||
return document.querySelector<HTMLMetaElement>('meta[name=\"csrf-token\"]')?.content ?? '';
|
||||
}
|
||||
|
||||
async function persistLocale(locale: SupportedLocale): Promise<void> {
|
||||
const response = await fetch('/set-locale', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({ locale }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`locale update failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
import { SUPPORTED_LANGUAGES, getCurrentLocale, switchLocale, type SupportedLocale } from '../lib/locale';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { t } = useTranslation('common');
|
||||
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
|
||||
|
||||
const currentLocale = (i18n.language || document.documentElement.lang || 'de') as SupportedLocale;
|
||||
const currentLocale = getCurrentLocale();
|
||||
|
||||
const changeLanguage = React.useCallback(
|
||||
async (locale: SupportedLocale) => {
|
||||
@@ -55,12 +26,9 @@ export function LanguageSwitcher() {
|
||||
|
||||
setPendingLocale(locale);
|
||||
try {
|
||||
await persistLocale(locale);
|
||||
await i18n.changeLanguage(locale);
|
||||
document.documentElement.setAttribute('lang', locale);
|
||||
await switchLocale(locale);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
console.error('Failed to switch language', error);
|
||||
}
|
||||
} finally {
|
||||
|
||||
306
resources/js/admin/components/NotificationCenter.tsx
Normal file
306
resources/js/admin/components/NotificationCenter.tsx
Normal 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;
|
||||
}
|
||||
161
resources/js/admin/components/UserMenu.tsx
Normal file
161
resources/js/admin/components/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
|
||||
export const ADMIN_EVENTS_PATH = adminPath('/events');
|
||||
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
|
||||
export const ADMIN_PROFILE_PATH = adminPath('/settings/profile');
|
||||
export const ADMIN_FAQ_PATH = adminPath('/faq');
|
||||
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
|
||||
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
|
||||
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;
|
||||
|
||||
93
resources/js/admin/context/EventContext.tsx
Normal file
93
resources/js/admin/context/EventContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,18 +1,50 @@
|
||||
{
|
||||
"app": {
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
"event": "Event",
|
||||
"events": "Events",
|
||||
"photos": "Fotos",
|
||||
"tasks": "Aufgaben",
|
||||
"collections": "Aufgabenvorlagen",
|
||||
"emotions": "Emotionen",
|
||||
"engagement": "Aufgaben & Co.",
|
||||
"toolkit": "Toolkit",
|
||||
"billing": "Abrechnung",
|
||||
"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": {
|
||||
"de": "Deutsch",
|
||||
"en": "Englisch"
|
||||
|
||||
@@ -36,6 +36,17 @@
|
||||
"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": {
|
||||
"title": "Bereit für den Eventstart",
|
||||
"description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.",
|
||||
@@ -112,6 +123,31 @@
|
||||
"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": {
|
||||
"actions": {
|
||||
"newEvent": "Neues Event",
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
{
|
||||
"app": {
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
"event": "Event",
|
||||
"events": "Events",
|
||||
"photos": "Photos",
|
||||
"tasks": "Tasks",
|
||||
"collections": "Collections",
|
||||
"emotions": "Emotions",
|
||||
"engagement": "Tasks & More",
|
||||
"toolkit": "Toolkit",
|
||||
"billing": "Billing",
|
||||
"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": {
|
||||
"de": "German",
|
||||
"en": "English"
|
||||
|
||||
@@ -36,6 +36,17 @@
|
||||
"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": {
|
||||
"title": "Ready for event day",
|
||||
"description": "Complete these steps so guests can join without friction.",
|
||||
@@ -112,6 +123,31 @@
|
||||
"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 – we’ll extend this FAQ with your topics.",
|
||||
"contact": "Contact support"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actions": {
|
||||
"newEvent": "New Event",
|
||||
|
||||
40
resources/js/admin/lib/locale.ts
Normal file
40
resources/js/admin/lib/locale.ts
Normal 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);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import './i18n';
|
||||
import './dev-tools';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import { OnboardingProgressProvider } from './onboarding';
|
||||
import { EventProvider } from './context/EventContext';
|
||||
|
||||
const DevTenantSwitcher = React.lazy(() => import('./components/DevTenantSwitcher'));
|
||||
|
||||
@@ -41,6 +42,7 @@ createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<EventProvider>
|
||||
<OnboardingProgressProvider>
|
||||
<Suspense
|
||||
fallback={(
|
||||
@@ -52,6 +54,7 @@ createRoot(rootEl).render(
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</OnboardingProgressProvider>
|
||||
</EventProvider>
|
||||
</AuthProvider>
|
||||
{enableDevSwitcher ? (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
QrCode,
|
||||
ClipboardList,
|
||||
Package as PackageIcon,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
TenantOnboardingChecklistCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
SectionCard,
|
||||
SectionHeader,
|
||||
StatCarousel,
|
||||
@@ -52,6 +50,7 @@ import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
@@ -212,7 +211,7 @@ export default function DashboardPage() {
|
||||
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 greetingTitle = translate('welcome.greeting', { name: greetingName });
|
||||
@@ -224,6 +223,9 @@ export default function DashboardPage() {
|
||||
const publishedEvents = events.filter((event) => event.status === 'published');
|
||||
const primaryEvent = events[0] ?? 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 limitTranslate = React.useCallback(
|
||||
@@ -271,6 +273,31 @@ export default function DashboardPage() {
|
||||
}, [summary, events]);
|
||||
|
||||
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(
|
||||
() => ([
|
||||
{
|
||||
@@ -430,21 +457,33 @@ export default function DashboardPage() {
|
||||
'Alle Schritte abgeschlossen – großartig! Du kannst jederzeit zur Admin-App wechseln.'
|
||||
);
|
||||
const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten');
|
||||
const heroBadge = translate('overview.title', 'Kurzer Überblick');
|
||||
const heroDescription = translate(
|
||||
'overview.description',
|
||||
'Wichtigste Kennzahlen deines Tenants auf einen Blick.'
|
||||
);
|
||||
const marketingDashboardLabel = translate('onboarding.back_to_marketing', 'Marketing-Dashboard ansehen');
|
||||
const marketingDashboardDescription = translate(
|
||||
'onboarding.back_to_marketing_description',
|
||||
'Zur Zusammenfassung im Kundenportal wechseln.'
|
||||
);
|
||||
|
||||
const heroBadge = singleEvent
|
||||
? translate('overview.eventHero.badge', 'Aktives Event')
|
||||
: translate('overview.title', 'Kurzer Überblick');
|
||||
|
||||
const heroDescription = singleEvent
|
||||
? translate('overview.eventHero.description', 'Alles richtet sich nach {{event}}. Nächster Termin: {{date}}.', {
|
||||
event: singleEventName ?? '',
|
||||
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 heroPrimaryCtaLabel = readiness.hasEvent
|
||||
? translate('quickActions.moderatePhotos.label', 'Fotos moderieren')
|
||||
: translate('actions.newEvent');
|
||||
const heroPrimaryAction = (
|
||||
const heroSupporting = singleEvent
|
||||
? [
|
||||
translate('overview.eventHero.supporting.status', 'Status: {{status}}', {
|
||||
status: formatEventStatus(singleEvent.status ?? null, tc),
|
||||
}),
|
||||
singleEventDateLabel
|
||||
? translate('overview.eventHero.supporting.date', 'Eventdatum: {{date}}', { date: singleEventDateLabel })
|
||||
: translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt.'),
|
||||
].filter(Boolean)
|
||||
: [heroSupportingCopy];
|
||||
|
||||
const heroPrimaryAction = (() => {
|
||||
if (onboardingCompletion < 100) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
@@ -456,20 +495,39 @@ export default function DashboardPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{heroPrimaryCtaLabel}
|
||||
{translate('onboarding.hero.cta', 'Setup fortsetzen')}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
}
|
||||
|
||||
if (singleEvent?.slug) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => window.location.assign('/dashboard')}
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug))}
|
||||
>
|
||||
{marketingDashboardLabel}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
{translate('actions.openEvent', 'Event öffnen')}
|
||||
</Button>
|
||||
);
|
||||
const heroAside = (
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>{onboardingCardTitle}</span>
|
||||
@@ -480,9 +538,44 @@ export default function DashboardPage() {
|
||||
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
|
||||
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
|
||||
</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 readinessPendingLabel = translate('readiness.pending', 'Noch offen');
|
||||
const hasEventContext = readiness.hasEvent;
|
||||
|
||||
const quickActionItems = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -498,6 +591,7 @@ export default function DashboardPage() {
|
||||
description: translate('quickActions.moderatePhotos.description'),
|
||||
icon: <Camera className="h-5 w-5" />,
|
||||
onClick: () => navigate(ADMIN_EVENTS_PATH),
|
||||
disabled: !hasEventContext,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
@@ -505,6 +599,7 @@ export default function DashboardPage() {
|
||||
description: translate('quickActions.organiseTasks.description'),
|
||||
icon: <ClipboardList className="h-5 w-5" />,
|
||||
onClick: () => navigate(buildEngagementTabPath('tasks')),
|
||||
disabled: !hasEventContext,
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
@@ -513,18 +608,24 @@ export default function DashboardPage() {
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
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
|
||||
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)}
|
||||
@@ -533,8 +634,29 @@ export default function DashboardPage() {
|
||||
</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 (
|
||||
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={layoutActions}>
|
||||
<AdminLayout title={adminTitle} subtitle={adminSubtitle} actions={layoutActions}>
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
|
||||
@@ -548,14 +670,74 @@ export default function DashboardPage() {
|
||||
<>
|
||||
<TenantHeroCard
|
||||
badge={heroBadge}
|
||||
title={greetingTitle}
|
||||
title={heroTitle}
|
||||
description={heroDescription}
|
||||
supporting={[heroSupportingCopy]}
|
||||
supporting={heroSupporting}
|
||||
primaryAction={heroPrimaryAction}
|
||||
secondaryAction={heroSecondaryAction}
|
||||
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 && (
|
||||
<Card className="border-none bg-white/90 shadow-lg shadow-rose-100/50">
|
||||
<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 {
|
||||
if (typeof name === 'string' && name.trim().length > 0) {
|
||||
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({
|
||||
event,
|
||||
onView,
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Download,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Printer,
|
||||
@@ -23,8 +21,6 @@ import toast from 'react-hot-toast';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
EventToolkit,
|
||||
@@ -54,6 +50,7 @@ import {
|
||||
SectionCard,
|
||||
SectionHeader,
|
||||
ActionGrid,
|
||||
TenantHeroCard,
|
||||
} from '../components/tenant';
|
||||
|
||||
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.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(
|
||||
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
|
||||
@@ -240,8 +195,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
});
|
||||
}, [limitWarnings]);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
||||
<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 (
|
||||
<AdminLayout title={eventName} subtitle={subtitle}>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
@@ -276,6 +246,14 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
<WorkspaceSkeleton />
|
||||
) : event ? (
|
||||
<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 ?? []} />}
|
||||
|
||||
<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';
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const statusLabel = event.status === 'published'
|
||||
? t('events.status.published', 'Veröffentlicht')
|
||||
: event.status === 'draft'
|
||||
? t('events.status.draft', 'Entwurf')
|
||||
: t('events.status.archived', 'Archiviert');
|
||||
const statusLabel = getStatusLabel(event, t);
|
||||
|
||||
return (
|
||||
<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 {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
|
||||
@@ -67,7 +67,17 @@ export default function EventTasksPage() {
|
||||
setEvent(eventData);
|
||||
const assignedIds = new Set(eventTasksResponse.data.map((task) => task.id));
|
||||
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);
|
||||
} catch (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';
|
||||
|
||||
async function handleModeChange(checked: boolean) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getApiErrorMessage } from '../lib/apiError';
|
||||
import {
|
||||
adminPath,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_EDIT_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
@@ -69,6 +70,25 @@ export default function EventsPage() {
|
||||
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
|
||||
[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(
|
||||
() => [
|
||||
{
|
||||
@@ -125,18 +145,6 @@ export default function EventsPage() {
|
||||
'events.list.subtitle',
|
||||
'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(
|
||||
'events.list.hero.description',
|
||||
'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.'
|
||||
|
||||
80
resources/js/admin/pages/FaqPage.tsx
Normal file
80
resources/js/admin/pages/FaqPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlignLeft,
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Heading,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Minus,
|
||||
Plus,
|
||||
Printer,
|
||||
QrCode,
|
||||
@@ -27,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
||||
@@ -241,6 +244,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
const [zoomScale, setZoomScale] = React.useState(1);
|
||||
const [fitScale, setFitScale] = React.useState(1);
|
||||
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
|
||||
const [isCompact, setIsCompact] = React.useState(false);
|
||||
const fitScaleRef = React.useRef(1);
|
||||
const manualZoomRef = React.useRef(false);
|
||||
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -252,6 +256,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
const designerViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const draftSignatureRef = React.useRef<string | null>(null);
|
||||
const initialElementsRef = React.useRef<LayoutElement[]>([]);
|
||||
const activeCustomization = React.useMemo(
|
||||
() => draftCustomization ?? initialCustomization ?? null,
|
||||
[draftCustomization, initialCustomization],
|
||||
@@ -264,6 +269,34 @@ export function InviteLayoutCustomizerPanel({
|
||||
const appliedLayoutRef = React.useRef<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(
|
||||
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
|
||||
[],
|
||||
@@ -410,7 +443,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
const commitElements = React.useCallback(
|
||||
(producer: (current: LayoutElement[]) => LayoutElement[], options?: { silent?: boolean }) => {
|
||||
setElements((prev) => {
|
||||
const base = cloneElements(prev);
|
||||
const source = prev.length ? prev : initialElementsRef.current;
|
||||
const base = cloneElements(source.length ? source : []);
|
||||
const produced = producer(base);
|
||||
const normalized = normalizeElements(produced);
|
||||
if (elementsAreEqual(prev, normalized)) {
|
||||
@@ -514,6 +548,14 @@ export function InviteLayoutCustomizerPanel({
|
||||
}, [clampZoom, zoomScale, fitScale, previewMode]);
|
||||
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(
|
||||
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
|
||||
commitElements(
|
||||
@@ -646,6 +688,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
setInstructions([]);
|
||||
commitElements(() => [], { silent: true });
|
||||
resetHistory([]);
|
||||
initialElementsRef.current = [];
|
||||
appliedSignatureRef.current = null;
|
||||
appliedLayoutRef.current = layoutId;
|
||||
appliedInviteRef.current = inviteKey;
|
||||
@@ -723,12 +766,15 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
if (isCustomizedAdvanced) {
|
||||
const initialElements = normalizeElements(payloadToElements(newForm.elements));
|
||||
initialElementsRef.current = initialElements;
|
||||
commitElements(() => initialElements, { silent: true });
|
||||
resetHistory(initialElements);
|
||||
} else {
|
||||
const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize);
|
||||
commitElements(() => defaults, { silent: true });
|
||||
resetHistory(defaults);
|
||||
const normalizedDefaults = normalizeElements(defaults);
|
||||
initialElementsRef.current = normalizedDefaults;
|
||||
commitElements(() => normalizedDefaults, { silent: true });
|
||||
resetHistory(normalizedDefaults);
|
||||
}
|
||||
|
||||
appliedSignatureRef.current = incomingSignature ?? null;
|
||||
@@ -1515,6 +1561,38 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
|
||||
@@ -1525,14 +1603,13 @@ 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>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
||||
<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.sections.layouts', 'Layouts')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
|
||||
</header>
|
||||
|
||||
<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={cn('order-2 space-y-6', 'xl:order-1')}>
|
||||
{renderResponsiveSection(
|
||||
'layouts',
|
||||
t('invites.customizer.sections.layouts', 'Layouts'),
|
||||
t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.'),
|
||||
<>
|
||||
<Select
|
||||
value={activeLayout?.id ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
@@ -1570,18 +1647,14 @@ export function InviteLayoutCustomizerPanel({
|
||||
{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">
|
||||
{sortedElements.map((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.')}
|
||||
</p>
|
||||
</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">
|
||||
<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="instructions">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</TabsTrigger>
|
||||
<TabsTrigger value="branding">{t('invites.customizer.sections.branding', 'Farbgebung')}</TabsTrigger>
|
||||
@@ -1825,19 +1902,21 @@ export function InviteLayoutCustomizerPanel({
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
|
||||
{renderActionButtons('inline')}
|
||||
</div>
|
||||
<div ref={actionsSentinelRef} className="h-1 w-full" />
|
||||
</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 gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
</span>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={ZOOM_MIN}
|
||||
@@ -1853,6 +1932,30 @@ export function InviteLayoutCustomizerPanel({
|
||||
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">
|
||||
<ToggleGroupItem value="fit" className="px-2 text-xs">
|
||||
Fit
|
||||
@@ -1861,6 +1964,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
100%
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{!isCompact ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1875,6 +1979,22 @@ export function InviteLayoutCustomizerPanel({
|
||||
>
|
||||
{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 className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
|
||||
@@ -312,11 +312,29 @@ export function DesignerCanvas({
|
||||
canvas.on('selection:cleared', handleSelectionCleared);
|
||||
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 () => {
|
||||
canvas.off('selection:created', handleSelection);
|
||||
canvas.off('selection:updated', handleSelection);
|
||||
canvas.off('selection:cleared', handleSelectionCleared);
|
||||
canvas.off('object:modified', handleObjectModified);
|
||||
canvas.off('editing:exited', handleEditingExited);
|
||||
};
|
||||
}, [onChange, onSelect, readOnly]);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const BillingPage = React.lazy(() => import('./pages/BillingPage'));
|
||||
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
|
||||
const TaskCollectionsPage = React.lazy(() => import('./pages/TaskCollectionsPage'));
|
||||
const EmotionsPage = React.lazy(() => import('./pages/EmotionsPage'));
|
||||
const FaqPage = React.lazy(() => import('./pages/FaqPage'));
|
||||
const AuthCallbackPage = React.lazy(() => import('./pages/AuthCallbackPage'));
|
||||
const WelcomeTeaserPage = React.lazy(() => import('./pages/WelcomeTeaserPage'));
|
||||
const LoginStartPage = React.lazy(() => import('./pages/LoginStartPage'));
|
||||
@@ -101,6 +102,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'emotions', element: <EmotionsPage /> },
|
||||
{ path: 'billing', element: <BillingPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'faq', element: <FaqPage /> },
|
||||
{ path: 'settings/profile', element: <ProfilePage /> },
|
||||
{ path: 'welcome', element: <WelcomeLandingPage /> },
|
||||
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
|
||||
|
||||
45
resources/js/lib/coupons.ts
Normal file
45
resources/js/lib/coupons.ts
Normal 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;
|
||||
}
|
||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
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 {
|
||||
id: number;
|
||||
@@ -52,6 +55,10 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
|
||||
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 { auth } = props as any;
|
||||
const { t } = useTranslation('marketing');
|
||||
@@ -75,6 +82,19 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
}
|
||||
}, [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 = [
|
||||
{ name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), 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)
|
||||
: false;
|
||||
|
||||
const appliedCouponCode = couponPreview?.coupon.code ?? null;
|
||||
const purchaseUrl = selectedPackage
|
||||
? `/purchase-wizard/${selectedPackage.id}${appliedCouponCode ? `?coupon=${appliedCouponCode}` : ''}`
|
||||
: '#';
|
||||
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
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
|
||||
|
||||
const getFeatureIcon = (feature: string) => {
|
||||
@@ -795,6 +854,52 @@ function PackageCard({
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</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
|
||||
asChild
|
||||
className={cn(
|
||||
@@ -804,12 +909,18 @@ function PackageCard({
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/purchase-wizard/${selectedPackage.id}`}
|
||||
href={purchaseUrl}
|
||||
onClick={() => {
|
||||
if (selectedPackage) {
|
||||
handleCtaClick(selectedPackage, selectedVariant);
|
||||
}
|
||||
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
|
||||
}
|
||||
|
||||
if (appliedCouponCode) {
|
||||
localStorage.setItem('preferred_coupon_code', appliedCouponCode);
|
||||
} else {
|
||||
localStorage.removeItem('preferred_coupon_code');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('packages.to_order')}
|
||||
|
||||
@@ -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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { LoaderCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||
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';
|
||||
|
||||
@@ -108,8 +112,26 @@ export const PaymentStep: React.FC = () => {
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [initialised, setInitialised] = 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 eventCallbackRef = useRef<(event: any) => void>();
|
||||
const hasAutoAppliedCoupon = useRef(false);
|
||||
const checkoutContainerClass = 'paddle-checkout-container';
|
||||
|
||||
const paddleLocale = useMemo(() => {
|
||||
@@ -118,6 +140,84 @@ export const PaymentStep: React.FC = () => {
|
||||
}, [i18n.language]);
|
||||
|
||||
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 () => {
|
||||
setPaymentCompleted(true);
|
||||
@@ -209,6 +309,7 @@ export const PaymentStep: React.FC = () => {
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
locale: paddleLocale,
|
||||
coupon_code: couponPreview?.coupon.code ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -348,6 +449,26 @@ export const PaymentStep: React.FC = () => {
|
||||
setPaymentCompleted(false);
|
||||
}, [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) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
@@ -377,6 +498,63 @@ export const PaymentStep: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<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 && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
35
resources/js/types/coupon.ts
Normal file
35
resources/js/types/coupon.ts
Normal 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;
|
||||
}
|
||||
@@ -198,4 +198,31 @@ return [
|
||||
'contact' => [
|
||||
'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.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -198,4 +198,31 @@ return [
|
||||
'contact' => [
|
||||
'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.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\EventPublicController;
|
||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||
use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||
@@ -27,6 +28,12 @@ use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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'])
|
||||
->middleware('throttle:60,1')
|
||||
->name('webhooks.revenuecat');
|
||||
|
||||
72
tests/Feature/Api/Marketing/CouponPreviewTest.php
Normal file
72
tests/Feature/Api/Marketing/CouponPreviewTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
48
tests/Feature/Console/CouponExportCommandTest.php
Normal file
48
tests/Feature/Console/CouponExportCommandTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
93
tests/Feature/PaddleCheckoutControllerTest.php
Normal file
93
tests/Feature/PaddleCheckoutControllerTest.php
Normal 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
64
tests/Unit/CouponTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
46
tests/Unit/TenantRequestResolverTest.php
Normal file
46
tests/Unit/TenantRequestResolverTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user