From 082b78cd432c6102d57aace1aa6f09818fdb0986 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 9 Nov 2025 20:26:50 +0100 Subject: [PATCH] =?UTF-8?q?coupon=20code=20system=20eingef=C3=BChrt.=20cou?= =?UTF-8?q?pons=20werden=20vom=20super=20admin=20gemanaged.=20coupons=20we?= =?UTF-8?q?rden=20mit=20paddle=20synchronisiert=20und=20dort=20validiert.?= =?UTF-8?q?=20plus:=20einige=20mobil-optimierungen=20im=20tenant=20admin?= =?UTF-8?q?=20pwa.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ExportCouponRedemptions.php | 56 +++ app/Enums/CouponStatus.php | 23 ++ app/Enums/CouponType.php | 19 + .../Resources/Coupons/CouponResource.php | 123 +++++++ .../Resources/Coupons/Pages/CreateCoupon.php | 17 + .../Resources/Coupons/Pages/EditCoupon.php | 32 ++ .../Resources/Coupons/Pages/ListCoupons.php | 19 + .../Resources/Coupons/Pages/ViewCoupon.php | 19 + .../RedemptionsRelationManager.php | 72 ++++ .../Resources/Coupons/Schemas/CouponForm.php | 148 ++++++++ .../Coupons/Schemas/CouponInfolist.php | 81 +++++ .../Resources/Coupons/Tables/CouponsTable.php | 113 ++++++ app/Filament/Widgets/CouponUsageWidget.php | 60 ++++ .../Api/Marketing/CouponPreviewController.php | 76 ++++ .../Api/Tenant/EmotionController.php | 19 +- .../Api/Tenant/SettingsController.php | 2 +- .../Api/Tenant/TaskCollectionController.php | 17 +- .../Controllers/Api/Tenant/TaskController.php | 54 ++- app/Http/Controllers/MarketingController.php | 50 ++- .../Controllers/PaddleCheckoutController.php | 17 +- app/Http/Requests/Tenant/TaskStoreRequest.php | 12 +- .../Requests/Tenant/TaskUpdateRequest.php | 12 +- app/Http/Resources/Tenant/TaskResource.php | 5 + app/Jobs/SyncCouponToPaddle.php | 68 ++++ app/Models/CheckoutSession.php | 10 + app/Models/Coupon.php | 189 ++++++++++ app/Models/CouponRedemption.php | 84 +++++ app/Models/Package.php | 6 + app/Providers/AppServiceProvider.php | 7 + .../Filament/SuperAdminPanelProvider.php | 29 +- .../Checkout/CheckoutSessionService.php | 31 ++ .../Checkout/CheckoutWebhookService.php | 4 + .../Coupons/CouponRedemptionService.php | 72 ++++ app/Services/Coupons/CouponService.php | 287 +++++++++++++++ app/Services/Paddle/PaddleCheckoutService.php | 6 +- app/Services/Paddle/PaddleDiscountService.php | 149 ++++++++ app/Support/JoinTokenLayoutRegistry.php | 95 ++--- app/Support/TenantRequestResolver.php | 41 +++ bootstrap/app.php | 1 + database/factories/CouponFactory.php | 51 +++ .../factories/CouponRedemptionFactory.php | 35 ++ ...2025_11_07_142138_create_coupons_table.php | 61 ++++ ..._07_142206_create_coupon_package_table.php | 33 ++ ...142223_create_coupon_redemptions_table.php | 47 +++ ...upon_fields_to_checkout_sessions_table.php | 34 ++ docs/changes/2025-11-08-coupon-ops.md | 12 + resources/js/admin/api.ts | 9 + resources/js/admin/components/AdminLayout.tsx | 135 ++++--- resources/js/admin/components/EventNav.tsx | 225 ++++++++++++ .../js/admin/components/LanguageSwitcher.tsx | 38 +- .../admin/components/NotificationCenter.tsx | 306 ++++++++++++++++ resources/js/admin/components/UserMenu.tsx | 161 +++++++++ resources/js/admin/constants.ts | 1 + resources/js/admin/context/EventContext.tsx | 93 +++++ .../js/admin/i18n/locales/de/common.json | 34 +- .../js/admin/i18n/locales/de/dashboard.json | 36 ++ .../js/admin/i18n/locales/en/common.json | 34 +- .../js/admin/i18n/locales/en/dashboard.json | 36 ++ resources/js/admin/lib/locale.ts | 40 +++ resources/js/admin/main.tsx | 25 +- resources/js/admin/pages/DashboardPage.tsx | 328 +++++++++++++----- resources/js/admin/pages/EventDetailPage.tsx | 160 ++++++--- resources/js/admin/pages/EventTasksPage.tsx | 16 +- resources/js/admin/pages/EventsPage.tsx | 32 +- resources/js/admin/pages/FaqPage.tsx | 80 +++++ .../InviteLayoutCustomizerPanel.tsx | 306 +++++++++++----- .../invite-layout/DesignerCanvas.tsx | 18 + resources/js/admin/router.tsx | 2 + resources/js/lib/coupons.ts | 45 +++ resources/js/pages/marketing/Packages.tsx | 131 ++++++- .../marketing/checkout/steps/PaymentStep.tsx | 182 +++++++++- resources/js/types/coupon.ts | 35 ++ resources/lang/de/marketing.php | 27 ++ resources/lang/en/marketing.php | 27 ++ routes/api.php | 7 + .../Api/Marketing/CouponPreviewTest.php | 72 ++++ .../Console/CouponExportCommandTest.php | 48 +++ .../Feature/PaddleCheckoutControllerTest.php | 93 +++++ tests/Unit/CouponTest.php | 64 ++++ tests/Unit/TenantRequestResolverTest.php | 46 +++ 80 files changed, 4855 insertions(+), 435 deletions(-) create mode 100644 app/Console/Commands/ExportCouponRedemptions.php create mode 100644 app/Enums/CouponStatus.php create mode 100644 app/Enums/CouponType.php create mode 100644 app/Filament/Resources/Coupons/CouponResource.php create mode 100644 app/Filament/Resources/Coupons/Pages/CreateCoupon.php create mode 100644 app/Filament/Resources/Coupons/Pages/EditCoupon.php create mode 100644 app/Filament/Resources/Coupons/Pages/ListCoupons.php create mode 100644 app/Filament/Resources/Coupons/Pages/ViewCoupon.php create mode 100644 app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php create mode 100644 app/Filament/Resources/Coupons/Schemas/CouponForm.php create mode 100644 app/Filament/Resources/Coupons/Schemas/CouponInfolist.php create mode 100644 app/Filament/Resources/Coupons/Tables/CouponsTable.php create mode 100644 app/Filament/Widgets/CouponUsageWidget.php create mode 100644 app/Http/Controllers/Api/Marketing/CouponPreviewController.php create mode 100644 app/Jobs/SyncCouponToPaddle.php create mode 100644 app/Models/Coupon.php create mode 100644 app/Models/CouponRedemption.php create mode 100644 app/Services/Coupons/CouponRedemptionService.php create mode 100644 app/Services/Coupons/CouponService.php create mode 100644 app/Services/Paddle/PaddleDiscountService.php create mode 100644 app/Support/TenantRequestResolver.php create mode 100644 database/factories/CouponFactory.php create mode 100644 database/factories/CouponRedemptionFactory.php create mode 100644 database/migrations/2025_11_07_142138_create_coupons_table.php create mode 100644 database/migrations/2025_11_07_142206_create_coupon_package_table.php create mode 100644 database/migrations/2025_11_07_142223_create_coupon_redemptions_table.php create mode 100644 database/migrations/2025_11_07_142240_add_coupon_fields_to_checkout_sessions_table.php create mode 100644 docs/changes/2025-11-08-coupon-ops.md create mode 100644 resources/js/admin/components/EventNav.tsx create mode 100644 resources/js/admin/components/NotificationCenter.tsx create mode 100644 resources/js/admin/components/UserMenu.tsx create mode 100644 resources/js/admin/context/EventContext.tsx create mode 100644 resources/js/admin/lib/locale.ts create mode 100644 resources/js/admin/pages/FaqPage.tsx create mode 100644 resources/js/lib/coupons.ts create mode 100644 resources/js/types/coupon.ts create mode 100644 tests/Feature/Api/Marketing/CouponPreviewTest.php create mode 100644 tests/Feature/Console/CouponExportCommandTest.php create mode 100644 tests/Feature/PaddleCheckoutControllerTest.php create mode 100644 tests/Unit/CouponTest.php create mode 100644 tests/Unit/TenantRequestResolverTest.php diff --git a/app/Console/Commands/ExportCouponRedemptions.php b/app/Console/Commands/ExportCouponRedemptions.php new file mode 100644 index 0000000..68b27c1 --- /dev/null +++ b/app/Console/Commands/ExportCouponRedemptions.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/app/Enums/CouponStatus.php b/app/Enums/CouponStatus.php new file mode 100644 index 0000000..107700d --- /dev/null +++ b/app/Enums/CouponStatus.php @@ -0,0 +1,23 @@ + __('Draft'), + self::ACTIVE => __('Active'), + self::SCHEDULED => __('Scheduled'), + self::PAUSED => __('Paused'), + self::ARCHIVED => __('Archived'), + }; + } +} diff --git a/app/Enums/CouponType.php b/app/Enums/CouponType.php new file mode 100644 index 0000000..8d347a5 --- /dev/null +++ b/app/Enums/CouponType.php @@ -0,0 +1,19 @@ + __('Percentage'), + self::FLAT => __('Flat amount'), + self::FLAT_PER_SEAT => __('Flat per seat'), + }; + } +} diff --git a/app/Filament/Resources/Coupons/CouponResource.php b/app/Filament/Resources/Coupons/CouponResource.php new file mode 100644 index 0000000..356d805 --- /dev/null +++ b/app/Filament/Resources/Coupons/CouponResource.php @@ -0,0 +1,123 @@ + 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; + } +} diff --git a/app/Filament/Resources/Coupons/Pages/CreateCoupon.php b/app/Filament/Resources/Coupons/Pages/CreateCoupon.php new file mode 100644 index 0000000..f90a157 --- /dev/null +++ b/app/Filament/Resources/Coupons/Pages/CreateCoupon.php @@ -0,0 +1,17 @@ +record); + } +} diff --git a/app/Filament/Resources/Coupons/Pages/EditCoupon.php b/app/Filament/Resources/Coupons/Pages/EditCoupon.php new file mode 100644 index 0000000..f1cf47c --- /dev/null +++ b/app/Filament/Resources/Coupons/Pages/EditCoupon.php @@ -0,0 +1,32 @@ +after(fn ($record) => SyncCouponToPaddle::dispatch($record, true)), + ForceDeleteAction::make(), + RestoreAction::make(), + ]; + } + + protected function afterSave(): void + { + SyncCouponToPaddle::dispatch($this->record); + } +} diff --git a/app/Filament/Resources/Coupons/Pages/ListCoupons.php b/app/Filament/Resources/Coupons/Pages/ListCoupons.php new file mode 100644 index 0000000..540ded1 --- /dev/null +++ b/app/Filament/Resources/Coupons/Pages/ListCoupons.php @@ -0,0 +1,19 @@ +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([]); + } +} diff --git a/app/Filament/Resources/Coupons/Schemas/CouponForm.php b/app/Filament/Resources/Coupons/Schemas/CouponForm.php new file mode 100644 index 0000000..5ab68e2 --- /dev/null +++ b/app/Filament/Resources/Coupons/Schemas/CouponForm.php @@ -0,0 +1,148 @@ +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)), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Coupons/Schemas/CouponInfolist.php b/app/Filament/Resources/Coupons/Schemas/CouponInfolist.php new file mode 100644 index 0000000..4ed5309 --- /dev/null +++ b/app/Filament/Resources/Coupons/Schemas/CouponInfolist.php @@ -0,0 +1,81 @@ +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'), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Coupons/Tables/CouponsTable.php b/app/Filament/Resources/Coupons/Tables/CouponsTable.php new file mode 100644 index 0000000..88bb1d3 --- /dev/null +++ b/app/Filament/Resources/Coupons/Tables/CouponsTable.php @@ -0,0 +1,113 @@ +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(), + ]), + ]); + } +} diff --git a/app/Filament/Widgets/CouponUsageWidget.php b/app/Filament/Widgets/CouponUsageWidget.php new file mode 100644 index 0000000..7c9714d --- /dev/null +++ b/app/Filament/Widgets/CouponUsageWidget.php @@ -0,0 +1,60 @@ +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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Marketing/CouponPreviewController.php b/app/Http/Controllers/Api/Marketing/CouponPreviewController.php new file mode 100644 index 0000000..255f4ba --- /dev/null +++ b/app/Http/Controllers/Api/Marketing/CouponPreviewController.php @@ -0,0 +1,76 @@ +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'], + ]); + } +} diff --git a/app/Http/Controllers/Api/Tenant/EmotionController.php b/app/Http/Controllers/Api/Tenant/EmotionController.php index 2a457ca..5e89531 100644 --- a/app/Http/Controllers/Api/Tenant/EmotionController.php +++ b/app/Http/Controllers/Api/Tenant/EmotionController.php @@ -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); + } } diff --git a/app/Http/Controllers/Api/Tenant/SettingsController.php b/app/Http/Controllers/Api/Tenant/SettingsController.php index 6d65da5..ba6ebcf 100644 --- a/app/Http/Controllers/Api/Tenant/SettingsController.php +++ b/app/Http/Controllers/Api/Tenant/SettingsController.php @@ -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([ diff --git a/app/Http/Controllers/Api/Tenant/TaskCollectionController.php b/app/Http/Controllers/Api/Tenant/TaskCollectionController.php index 1ebad1b..1fb2249 100644 --- a/app/Http/Controllers/Api/Tenant/TaskCollectionController.php +++ b/app/Http/Controllers/Api/Tenant/TaskCollectionController.php @@ -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); + } } diff --git a/app/Http/Controllers/Api/Tenant/TaskController.php b/app/Http/Controllers/Api/Tenant/TaskController.php index 19d8721..d42bc75 100644 --- a/app/Http/Controllers/Api/Tenant/TaskController.php +++ b/app/Http/Controllers/Api/Tenant/TaskController.php @@ -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)) { diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 3e66c90..0530ca3 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -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()); diff --git a/app/Http/Controllers/PaddleCheckoutController.php b/app/Http/Controllers/PaddleCheckoutController.php index 7baae02..4bcdae3 100644 --- a/app/Http/Controllers/PaddleCheckoutController.php +++ b/app/Http/Controllers/PaddleCheckoutController.php @@ -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([ diff --git a/app/Http/Requests/Tenant/TaskStoreRequest.php b/app/Http/Requests/Tenant/TaskStoreRequest.php index 5ea2bbf..138a398 100644 --- a/app/Http/Requests/Tenant/TaskStoreRequest.php +++ b/app/Http/Requests/Tenant/TaskStoreRequest.php @@ -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.'); } }], diff --git a/app/Http/Requests/Tenant/TaskUpdateRequest.php b/app/Http/Requests/Tenant/TaskUpdateRequest.php index f5cc3af..6d42ab6 100644 --- a/app/Http/Requests/Tenant/TaskUpdateRequest.php +++ b/app/Http/Requests/Tenant/TaskUpdateRequest.php @@ -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.'); } }], diff --git a/app/Http/Resources/Tenant/TaskResource.php b/app/Http/Resources/Tenant/TaskResource.php index 19ad939..73f6469 100644 --- a/app/Http/Resources/Tenant/TaskResource.php +++ b/app/Http/Resources/Tenant/TaskResource.php @@ -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, diff --git a/app/Jobs/SyncCouponToPaddle.php b/app/Jobs/SyncCouponToPaddle.php new file mode 100644 index 0000000..d97af06 --- /dev/null +++ b/app/Jobs/SyncCouponToPaddle.php @@ -0,0 +1,68 @@ +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; + } + } +} diff --git a/app/Models/CheckoutSession.php b/app/Models/CheckoutSession.php index 95b1c6d..65230f8 100644 --- a/app/Models/CheckoutSession.php +++ b/app/Models/CheckoutSession.php @@ -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', [ diff --git a/app/Models/Coupon.php b/app/Models/Coupon.php new file mode 100644 index 0000000..82ab153 --- /dev/null +++ b/app/Models/Coupon.php @@ -0,0 +1,189 @@ + */ + 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, + ); + } +} diff --git a/app/Models/CouponRedemption.php b/app/Models/CouponRedemption.php new file mode 100644 index 0000000..d0b996b --- /dev/null +++ b/app/Models/CouponRedemption.php @@ -0,0 +1,84 @@ + */ + 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(); + } +} diff --git a/app/Models/Package.php b/app/Models/Package.php index dae4041..f395d5f 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -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'; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6a51dc0..6602765 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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'); diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index 500d6c8..ea88ae9 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -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,20 +25,12 @@ 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 { public function panel(Panel $panel): Panel { - + return $panel ->default() ->id('superadmin') @@ -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') - - // SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation - // Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert - ; + ->authGuard('web'); + + // SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation + // Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert } } diff --git a/app/Services/Checkout/CheckoutSessionService.php b/app/Services/Checkout/CheckoutSessionService.php index d7cb7a1..a653208 100644 --- a/app/Services/Checkout/CheckoutSessionService.php +++ b/app/Services/Checkout/CheckoutSessionService.php @@ -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); diff --git a/app/Services/Checkout/CheckoutWebhookService.php b/app/Services/Checkout/CheckoutWebhookService.php index 4bb4556..8b695a1 100644 --- a/app/Services/Checkout/CheckoutWebhookService.php +++ b/app/Services/Checkout/CheckoutWebhookService.php @@ -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; diff --git a/app/Services/Coupons/CouponRedemptionService.php b/app/Services/Coupons/CouponRedemptionService.php new file mode 100644 index 0000000..170e9ad --- /dev/null +++ b/app/Services/Coupons/CouponRedemptionService.php @@ -0,0 +1,72 @@ +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, + ]), + ], + ); + } +} diff --git a/app/Services/Coupons/CouponService.php b/app/Services/Coupons/CouponService.php new file mode 100644 index 0000000..ccd4344 --- /dev/null +++ b/app/Services/Coupons/CouponService.php @@ -0,0 +1,287 @@ +, 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, 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', + }; + } +} diff --git a/app/Services/Paddle/PaddleCheckoutService.php b/app/Services/Paddle/PaddleCheckoutService.php index e69ee2c..8740145 100644 --- a/app/Services/Paddle/PaddleCheckoutService.php +++ b/app/Services/Paddle/PaddleCheckoutService.php @@ -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; } diff --git a/app/Services/Paddle/PaddleDiscountService.php b/app/Services/Paddle/PaddleDiscountService.php new file mode 100644 index 0000000..3820381 --- /dev/null +++ b/app/Services/Paddle/PaddleDiscountService.php @@ -0,0 +1,149 @@ + + */ + public function createDiscount(Coupon $coupon): array + { + $payload = $this->buildDiscountPayload($coupon); + + $response = $this->client->post('/discounts', $payload); + + return Arr::get($response, 'data', $response); + } + + /** + * @return array + */ + 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 $items + * @param array{currency?: string, address?: array{country_code: string, postal_code?: string}, customer_id?: string, address_id?: string} $context + * @return array + */ + 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 + */ + 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(); + } +} diff --git a/app/Support/JoinTokenLayoutRegistry.php b/app/Support/JoinTokenLayoutRegistry.php index 6edf163..59efced 100644 --- a/app/Support/JoinTokenLayoutRegistry.php +++ b/app/Support/JoinTokenLayoutRegistry.php @@ -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.', ], ], ]; diff --git a/app/Support/TenantRequestResolver.php b/app/Support/TenantRequestResolver.php new file mode 100644 index 0000000..7d54714 --- /dev/null +++ b/app/Support/TenantRequestResolver.php @@ -0,0 +1,41 @@ +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 + )); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 9779bc8..c0e9566 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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'); diff --git a/database/factories/CouponFactory.php b/database/factories/CouponFactory.php new file mode 100644 index 0000000..2713bdb --- /dev/null +++ b/database/factories/CouponFactory.php @@ -0,0 +1,51 @@ + + */ +class CouponFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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', + ]; + } +} diff --git a/database/factories/CouponRedemptionFactory.php b/database/factories/CouponRedemptionFactory.php new file mode 100644 index 0000000..825dbcb --- /dev/null +++ b/database/factories/CouponRedemptionFactory.php @@ -0,0 +1,35 @@ + + */ +class CouponRedemptionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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(), + ]; + } +} diff --git a/database/migrations/2025_11_07_142138_create_coupons_table.php b/database/migrations/2025_11_07_142138_create_coupons_table.php new file mode 100644 index 0000000..be03e1c --- /dev/null +++ b/database/migrations/2025_11_07_142138_create_coupons_table.php @@ -0,0 +1,61 @@ +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'); + } +}; diff --git a/database/migrations/2025_11_07_142206_create_coupon_package_table.php b/database/migrations/2025_11_07_142206_create_coupon_package_table.php new file mode 100644 index 0000000..7111d7d --- /dev/null +++ b/database/migrations/2025_11_07_142206_create_coupon_package_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2025_11_07_142223_create_coupon_redemptions_table.php b/database/migrations/2025_11_07_142223_create_coupon_redemptions_table.php new file mode 100644 index 0000000..c333aa6 --- /dev/null +++ b/database/migrations/2025_11_07_142223_create_coupon_redemptions_table.php @@ -0,0 +1,47 @@ +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'); + } +}; diff --git a/database/migrations/2025_11_07_142240_add_coupon_fields_to_checkout_sessions_table.php b/database/migrations/2025_11_07_142240_add_coupon_fields_to_checkout_sessions_table.php new file mode 100644 index 0000000..be55fef --- /dev/null +++ b/database/migrations/2025_11_07_142240_add_coupon_fields_to_checkout_sessions_table.php @@ -0,0 +1,34 @@ +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']); + }); + } +}; diff --git a/docs/changes/2025-11-08-coupon-ops.md b/docs/changes/2025-11-08-coupon-ops.md new file mode 100644 index 0000000..a844e5a --- /dev/null +++ b/docs/changes/2025-11-08-coupon-ops.md @@ -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. diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 264ac09..a41c1a2 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -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, diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx index 53beefc..955fa56 100644 --- a/resources/js/admin/components/AdminLayout.tsx +++ b/resources/js/admin/components/AdminLayout.tsx @@ -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>; + 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>(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(() => [ + { + 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
+ {actions} - + +
+
@@ -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({ />
- {items.map(({ to, labelKey, icon: Icon, end }) => ( + {items.map((item) => ( 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' + ) ) } > - - {t(labelKey)} + + {item.label} ))}
diff --git a/resources/js/admin/components/EventNav.tsx b/resources/js/admin/components/EventNav.tsx new file mode 100644 index 0000000..dab1658 --- /dev/null +++ b/resources/js/admin/components/EventNav.tsx @@ -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['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 ( + + + + + + + {t('eventSwitcher.title', 'Event auswählen')} + + {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.')} + + +
+ {events.length === 0 ? ( +
+ {t('eventSwitcher.noEvents', 'Noch keine Events vorhanden.')} +
+ ) : ( +
+ {events.map((event) => { + const isActive = activeEvent?.id === event.id; + const date = formatEventDate(event.event_date, locale); + return ( + + ); + })} +
+ )} + + {activeEvent?.slug ? ( +
+

+ {t('eventSwitcher.actions', 'Event-Funktionen')} +

+
+ {buildEventLinks(activeEvent.slug, t).map((action) => ( + + ))} +
+
+ ) : null} +
+
+
+ ); +} + +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 ( +
+
+ {links.map((link) => ( + + 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} + + ))} +
+
+ ); +} diff --git a/resources/js/admin/components/LanguageSwitcher.tsx b/resources/js/admin/components/LanguageSwitcher.tsx index 6e6ff63..478845f 100644 --- a/resources/js/admin/components/LanguageSwitcher.tsx +++ b/resources/js/admin/components/LanguageSwitcher.tsx @@ -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('meta[name=\"csrf-token\"]')?.content ?? ''; -} - -async function persistLocale(locale: SupportedLocale): Promise { - const response = await fetch('/set-locale', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': getCsrfToken(), - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ locale }), - credentials: 'include', - }); - - if (!response.ok) { - throw new Error(`locale update failed with status ${response.status}`); - } -} +import { SUPPORTED_LANGUAGES, getCurrentLocale, switchLocale, type SupportedLocale } from '../lib/locale'; export function LanguageSwitcher() { const { t } = useTranslation('common'); const [pendingLocale, setPendingLocale] = React.useState(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 { diff --git a/resources/js/admin/components/NotificationCenter.tsx b/resources/js/admin/components/NotificationCenter.tsx new file mode 100644 index 0000000..efd7474 --- /dev/null +++ b/resources/js/admin/components/NotificationCenter.tsx @@ -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([]); + const [dismissed, setDismissed] = React.useState>(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 = React.useMemo( + () => ({ + info: , + warning: , + success: , + }), + [] + ); + + return ( + { + setOpen(next); + if (next) { + refresh(); + } + }}> + + + + + + {t('notifications.title', { defaultValue: 'Notifications' })} + {!loading && unreadCount === 0 ? ( + {t('notifications.empty', { defaultValue: 'Aktuell ruhig' })} + ) : null} + + + {loading ? ( +
+ + +
+ ) : ( +
+ {visibleNotifications.length === 0 ? ( +

+ {t('notifications.empty.message', { defaultValue: 'Alles erledigt – wir melden uns bei Neuigkeiten.' })} +

+ ) : ( + visibleNotifications.map((item) => ( + event.preventDefault()}> +
+ {iconForTone[item.tone]} +
+

{item.title}

+ {item.description ? ( +

{item.description}

+ ) : null} +
+ {item.action ? ( + + ) : null} + +
+
+
+
+ )) + )} +
+ )} + + { + event.preventDefault(); + setDismissed(new Set()); + refresh(); + }} + > + + {t('notifications.action.refresh', { defaultValue: 'Neue Hinweise laden' })} + +
+
+ ); +} + +function buildNotifications({ + events, + summary, + navigate, + t, +}: { + events: TenantEvent[]; + summary: DashboardSummary | null; + navigate: ReturnType; + t: (key: string, options?: Record) => 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; +} diff --git a/resources/js/admin/components/UserMenu.tsx b/resources/js/admin/components/UserMenu.tsx new file mode 100644 index 0000000..4718221 --- /dev/null +++ b/resources/js/admin/components/UserMenu.tsx @@ -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(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 ( + + + + + + +

{user?.name ?? t('user.unknown')}

+ {user?.email ?

{user.email}

: null} +
+ + + goTo(ADMIN_PROFILE_PATH)}> + + {t('navigation.profile', { defaultValue: 'Profil' })} + + goTo(ADMIN_SETTINGS_PATH)}> + + {t('navigation.settings', { defaultValue: 'Einstellungen' })} + + goTo(ADMIN_BILLING_PATH)}> + + {t('navigation.billing', { defaultValue: 'Billing' })} + + + + + + + {t('app.languageSwitch')} + + + {SUPPORTED_LANGUAGES.map(({ code, labelKey }) => ( + { + event.preventDefault(); + changeLanguage(code); + }} + disabled={pendingLocale === code} + > + {t(labelKey)} + {currentLocale === code ? {t('app.languageActive', { defaultValue: 'Aktiv' })} : null} + + ))} + + + + + {appearance === 'dark' ? : appearance === 'light' ? : } + {t('app.theme', { defaultValue: 'Darstellung' })} + + + {(['light', 'dark', 'system'] as const).map((mode) => ( + changeAppearance(mode)}> + {mode === 'light' ? : mode === 'dark' ? : } + {t(`app.theme_${mode}`, { defaultValue: mode })} + + ))} + + + + goTo(ADMIN_FAQ_PATH)}> + + {t('app.help', { defaultValue: 'FAQ & Hilfe' })} + + { + event.preventDefault(); + logout(); + }} + > + + {t('app.logout', { defaultValue: 'Abmelden' })} + +
+
+ ); +} diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index a5e6ee9..99e32cd 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -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)}`; diff --git a/resources/js/admin/context/EventContext.tsx b/resources/js/admin/context/EventContext.tsx new file mode 100644 index 0000000..287b8de --- /dev/null +++ b/resources/js/admin/context/EventContext.tsx @@ -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(undefined); + +export function EventProvider({ children }: { children: React.ReactNode }) { + const [storedSlug, setStoredSlug] = React.useState(() => { + if (typeof window === 'undefined') { + return null; + } + return window.localStorage.getItem(STORAGE_KEY); + }); + + const { data: events = [], isLoading } = useQuery({ + 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( + () => ({ + events, + isLoading, + activeEvent, + selectEvent, + }), + [events, isLoading, activeEvent, selectEvent] + ); + + return {children}; +} + +export function useEventContext(): EventContextValue { + const ctx = React.useContext(EventContext); + if (!ctx) { + throw new Error('useEventContext must be used within an EventProvider'); + } + return ctx; +} diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 1f76b56..4963c4a 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -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" diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json index ab77713..1f14ee8 100644 --- a/resources/js/admin/i18n/locales/de/dashboard.json +++ b/resources/js/admin/i18n/locales/de/dashboard.json @@ -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", diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 56b5514..3fe7c2f 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -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" diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json index 545f2ef..0409e3c 100644 --- a/resources/js/admin/i18n/locales/en/dashboard.json +++ b/resources/js/admin/i18n/locales/en/dashboard.json @@ -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", diff --git a/resources/js/admin/lib/locale.ts b/resources/js/admin/lib/locale.ts new file mode 100644 index 0000000..c8e607e --- /dev/null +++ b/resources/js/admin/lib/locale.ts @@ -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('meta[name="csrf-token"]')?.content ?? ''; +} + +async function persistLocale(locale: SupportedLocale): Promise { + const response = await fetch('/set-locale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': getCsrfToken(), + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ locale }), + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(`locale update failed with status ${response.status}`); + } +} + +export function getCurrentLocale(): SupportedLocale { + return (i18n.language || document.documentElement.lang || 'de') as SupportedLocale; +} + +export async function switchLocale(locale: SupportedLocale): Promise { + await persistLocale(locale); + await i18n.changeLanguage(locale); + document.documentElement.setAttribute('lang', locale); +} diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx index 3e2152a..d83152b 100644 --- a/resources/js/admin/main.tsx +++ b/resources/js/admin/main.tsx @@ -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,17 +42,19 @@ createRoot(rootEl).render( - - - Oberfläche wird geladen … -
- )} - > - - - + + + + Oberfläche wird geladen … + + )} + > + + + + {enableDevSwitcher ? ( diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx index 0168674..8bc740e 100644 --- a/resources/js/admin/pages/DashboardPage.tsx +++ b/resources/js/admin/pages/DashboardPage.tsx @@ -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,46 +457,77 @@ 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 heroSecondaryAction = ( - - ); - const heroAside = ( + 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 ( + + ); + } + + if (singleEvent?.slug) { + return ( + + ); + } + + if (readiness.hasEvent) { + return ( + + ); + } + + return ( + + ); + })(); + + const heroAside = onboardingCompletion < 100 ? (
{onboardingCardTitle} @@ -480,9 +538,44 @@ export default function DashboardPage() {

{onboardingCardDescription}

- ); + ) : singleEvent ? ( + +
+
+

+ {translate('overview.eventHero.stats.title', 'Momentaufnahme')} +

+

+ {formatEventStatus(singleEvent.status ?? null, tc)} +

+
+
+
+
{translate('overview.eventHero.stats.date', 'Eventdatum')}
+
+ {singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Nicht gesetzt')} +
+
+
+
{translate('overview.eventHero.stats.uploads', 'Uploads gesamt')}
+
+ {Number(singleEvent.photo_count ?? 0).toLocaleString(i18n.language)} +
+
+
+
{translate('overview.eventHero.stats.tasks', 'Offene Aufgaben')}
+
+ {Number(singleEvent.tasks_count ?? 0).toLocaleString(i18n.language)} +
+
+
+
+
+ ) : 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: , onClick: () => navigate(ADMIN_EVENTS_PATH), + disabled: !hasEventContext, }, { key: 'tasks', @@ -505,6 +599,7 @@ export default function DashboardPage() { description: translate('quickActions.organiseTasks.description'), icon: , onClick: () => navigate(buildEngagementTabPath('tasks')), + disabled: !hasEventContext, }, { key: 'packages', @@ -513,18 +608,24 @@ export default function DashboardPage() { icon: , onClick: () => navigate(ADMIN_BILLING_PATH), }, - { - key: 'marketing', - label: marketingDashboardLabel, - description: marketingDashboardDescription, - icon: , - onClick: () => window.location.assign('/dashboard'), - }, ], - [translate, navigate, marketingDashboardLabel, marketingDashboardDescription], + [translate, navigate, hasEventContext], ); - const layoutActions = ( + const layoutActions = singleEvent ? ( + + ) : ( ); + 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 ( - + {errorMessage && ( {t('dashboard.alerts.errorTitle')} @@ -548,14 +670,74 @@ export default function DashboardPage() { <> + {liveEvents.length > 0 && ( + + + {liveNowTitle} + {liveNowDescription} + + + {liveEvents.map((event) => { + const name = resolveEventName(event.name, event.slug); + const dateLabel = event.event_date ? formatDate(event.event_date, dateLocale) : liveNoDate; + return ( +
+
+
+

{name}

+

{dateLabel}

+
+ {liveStatusLabel} +
+
+ + + +
+
+ ); + })} +
+
+ )} + {events.length === 0 && ( @@ -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): string { + const map: Record = { + 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 ( - -
- {label} - {icon} -
-
{value}
- {hint &&

{hint}

} -
- ); -} - function UpcomingEventRow({ event, onView, diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index 6e17772..55d0402 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -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 = ( -
- - {event && ( - <> - - - - - - - - )} -
- ); - - if (!slug) { - return ( - - -

- {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.')} -

-
-
- ); -} 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 ( + + +

+ {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.')} +

+
+
+ ); + } + return ( - + {error && ( {t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')} @@ -276,6 +246,14 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp ) : event ? (
+ { void load(); }} + loading={state.busy} + navigate={navigate} + /> + {(toolkitData?.alerts?.length ?? 0) > 0 && }
@@ -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; +}) { + 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 = ( +
+ } + label={t('events.workspace.fields.status', 'Status')} + value={statusLabel} + /> + } + label={t('events.workspace.fields.date', 'Eventdatum')} + value={formatDate(event.event_date)} + /> + } + 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')} + /> +
+ ); + + return ( + navigate(ADMIN_EVENTS_PATH)} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50"> + {t('events.actions.backToList', 'Zurück zur Liste')} + + )} + secondaryAction={( + + )} + aside={aside} + > +
+ +
+
+ ); +} + 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 ( @@ -839,6 +885,16 @@ function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; ); } +function getStatusLabel(event: TenantEvent, t: ReturnType['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); diff --git a/resources/js/admin/pages/EventTasksPage.tsx b/resources/js/admin/pages/EventTasksPage.tsx index c6f82b4..70bf820 100644 --- a/resources/js/admin/pages/EventTasksPage.tsx +++ b/resources/js/admin/pages/EventTasksPage.tsx @@ -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) { diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx index acda6ea..382e1ba 100644 --- a/resources/js/admin/pages/EventsPage.tsx +++ b/resources/js/admin/pages/EventsPage.tsx @@ -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.' diff --git a/resources/js/admin/pages/FaqPage.tsx b/resources/js/admin/pages/FaqPage.tsx new file mode 100644 index 0000000..69a4fe2 --- /dev/null +++ b/resources/js/admin/pages/FaqPage.tsx @@ -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 ( + + + + {t('faq.intro.title', 'Was dich erwartet')} + + {t( + 'faq.intro.description', + 'Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt.' + )} + + + + {entries.map((entry) => ( +
+

{entry.question}

+

{entry.answer}

+
+ ))} +
+

+ {t('faq.cta.needHelp', 'Fehlt dir etwas?')} +

+

+ {t( + 'faq.cta.description', + 'Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail – wir erweitern dieses FAQ mit deinen Themen.' + )} +

+ +
+
+
+
+ ); +} diff --git a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx index cf0cb22..4c3f02b 100644 --- a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx +++ b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx @@ -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(null); @@ -252,6 +256,7 @@ export function InviteLayoutCustomizerPanel({ const designerViewportRef = React.useRef(null); const canvasContainerRef = React.useRef(null); const draftSignatureRef = React.useRef(null); + const initialElementsRef = React.useRef([]); const activeCustomization = React.useMemo( () => draftCustomization ?? initialCustomization ?? null, [draftCustomization, initialCustomization], @@ -264,6 +269,34 @@ export function InviteLayoutCustomizerPanel({ const appliedLayoutRef = React.useRef(null); const appliedInviteRef = React.useRef(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 | ((element: LayoutElement) => Partial), 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 =
{content}
; + + if (!isCompact) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {body} +
+ ); + } + + return ( + + +
+

{title}

+ {description ?

{description}

: null} +
+ +
+ {body} +
+ ); + }, + [isCompact] + ); + return (
@@ -1525,63 +1603,58 @@ export function InviteLayoutCustomizerPanel({
{error}
) : null} -
-
-
-
-

{t('invites.customizer.sections.layouts', 'Layouts')}

-

{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}

-
+
+ + {renderResponsiveSection( + 'layouts', + t('invites.customizer.sections.layouts', 'Layouts'), + t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.'), + <> + - - - {activeLayout ? ( -
-

{activeLayout.name}

- {activeLayout.subtitle ?

{activeLayout.subtitle}

: null} - {activeLayout.description ?

{activeLayout.description}

: null} -
- ) : null} -
- -
-
-

- {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.')} -

-
+ {activeLayout ? ( +
+

{activeLayout.name}

+ {activeLayout.subtitle ?

{activeLayout.subtitle}

: null} + {activeLayout.description ?

{activeLayout.description}

: null} +
+ ) : null} + + )} + {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.'), + <>
{sortedElements.map((element) => { const Icon = elementIconFor(element); @@ -1652,16 +1725,20 @@ export function InviteLayoutCustomizerPanel({ {t('invites.customizer.elements.listHint', 'Wähle ein Element aus, um Einstellungen direkt unter dem Eintrag anzuzeigen.')}

-
+ + )} -
+ {renderResponsiveSection( + 'content', + t('invites.customizer.sections.content', 'Texte & Branding'), + t('invites.customizer.sections.contentHint', 'Passe Texte, Anleitungsschritte und Farben deiner Einladung an.'), - + {t('invites.customizer.sections.text', 'Texte')} {t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')} {t('invites.customizer.sections.branding', 'Farbgebung')} - +
@@ -1825,34 +1902,60 @@ export function InviteLayoutCustomizerPanel({
-
+ )}
{renderActionButtons('inline')}
-
+
{t('invites.customizer.controls.zoom', 'Zoom')} - { - manualZoomRef.current = true; - setZoomScale(clampZoom(Number(event.target.value))); - }} - className="h-1 w-36 overflow-hidden rounded-full" - disabled={previewMode === 'full'} - aria-label={t('invites.customizer.controls.zoom', 'Zoom')} - /> - {zoomPercent}% + {!isCompact ? ( + <> + { + manualZoomRef.current = true; + setZoomScale(clampZoom(Number(event.target.value))); + }} + className="h-1 w-36 overflow-hidden rounded-full" + disabled={previewMode === 'full'} + aria-label={t('invites.customizer.controls.zoom', 'Zoom')} + /> + {zoomPercent}% + + ) : ( +
+ + {zoomPercent}% + +
+ )} setPreviewMode(val as 'fit' | 'full')} className="flex"> Fit @@ -1861,20 +1964,37 @@ export function InviteLayoutCustomizerPanel({ 100% - + {!isCompact ? ( + + ) : ( + + )}
)} +
+ setCouponCode(event.target.value.toUpperCase())} + placeholder={t('coupon.placeholder')} + className="flex-1" + /> +
+ + {couponPreview && ( + + )} +
+
+ {couponError && ( +
+ + {couponError} +
+ )} + {couponPreview && ( +
+
+ + {t('coupon.applied', { code: couponPreview.coupon.code, amount: couponPreview.pricing.formatted.discount })} +
+
+
+ {t('coupon.fields.subtotal')} + {couponPreview.pricing.formatted.subtotal} +
+
+ {t('coupon.fields.discount')} + {couponPreview.pricing.formatted.discount} +
+
+ {t('coupon.fields.total')} + {couponPreview.pricing.formatted.total} +
+
+
+ )} + {couponPreview && ( + + )} +
+ + {couponError && ( +
+ + {couponError} +
+ )} + {couponNotice && ( +
+ + {couponNotice} +
+ )} + {couponPreview && ( +
+

{t('coupon.summary_title')}

+
+
+ {t('coupon.fields.subtotal')} + {couponPreview.pricing.formatted.subtotal} +
+
+ {t('coupon.fields.discount')} + {couponPreview.pricing.formatted.discount} +
+
+ {t('coupon.fields.tax')} + {couponPreview.pricing.formatted.tax} +
+ +
+ {t('coupon.fields.total')} + {couponPreview.pricing.formatted.total} +
+
+
+ )} +
+ {!inlineActive && (

diff --git a/resources/js/types/coupon.ts b/resources/js/types/coupon.ts new file mode 100644 index 0000000..7ffc0ee --- /dev/null +++ b/resources/js/types/coupon.ts @@ -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>; +} + +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; +} diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index 302f864..e74d6b3 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -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.', + ], + ], ]; diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index ec14d3c..c6412e4 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -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.', + ], + ], ]; diff --git a/routes/api.php b/routes/api.php index 1c176e4..339b8fd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ 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'); diff --git a/tests/Feature/Api/Marketing/CouponPreviewTest.php b/tests/Feature/Api/Marketing/CouponPreviewTest.php new file mode 100644 index 0000000..c2a7988 --- /dev/null +++ b/tests/Feature/Api/Marketing/CouponPreviewTest.php @@ -0,0 +1,72 @@ +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'); + } +} diff --git a/tests/Feature/Console/CouponExportCommandTest.php b/tests/Feature/Console/CouponExportCommandTest.php new file mode 100644 index 0000000..d4f5dfb --- /dev/null +++ b/tests/Feature/Console/CouponExportCommandTest.php @@ -0,0 +1,48 @@ +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); + } +} diff --git a/tests/Feature/PaddleCheckoutControllerTest.php b/tests/Feature/PaddleCheckoutControllerTest.php new file mode 100644 index 0000000..4b8f1fe --- /dev/null +++ b/tests/Feature/PaddleCheckoutControllerTest.php @@ -0,0 +1,93 @@ +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', + ]); + } +} diff --git a/tests/Unit/CouponTest.php b/tests/Unit/CouponTest.php new file mode 100644 index 0000000..9d8fa97 --- /dev/null +++ b/tests/Unit/CouponTest.php @@ -0,0 +1,64 @@ +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)); + } +} diff --git a/tests/Unit/TenantRequestResolverTest.php b/tests/Unit/TenantRequestResolverTest.php new file mode 100644 index 0000000..cc3007a --- /dev/null +++ b/tests/Unit/TenantRequestResolverTest.php @@ -0,0 +1,46 @@ +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); + } +}