übergang auf pakete, integration von stripe und paypal, blog hinzugefügt.

This commit is contained in:
Codex Agent
2025-09-29 07:59:39 +02:00
parent 0a643c3e4d
commit e52a4005aa
83 changed files with 4284 additions and 629 deletions

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\User;
use App\Models\TenantPackage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
class MigrateLegacyPurchases extends Command
{
protected $signature = 'packages:migrate-legacy';
protected $description = 'Migrate legacy purchases to new system with temp tenants';
public function handle()
{
$legacyPurchases = PackagePurchase::whereNull('tenant_id')->get();
if ($legacyPurchases->isEmpty()) {
$this->info('No legacy purchases found.');
return 0;
}
$this->info("Found {$legacyPurchases->count()} legacy purchases.");
foreach ($legacyPurchases as $purchase) {
if (!$purchase->user_id) {
// Create temp user if no user
$tempUser = User::create([
'name' => 'Legacy User ' . $purchase->id,
'email' => 'legacy' . $purchase->id . '@fotospiel.local',
'password' => Hash::make('legacy'),
'username' => 'legacy' . $purchase->id,
'first_name' => 'Legacy',
'last_name' => 'User',
'address' => 'Legacy Address',
'phone' => '000000000',
'email_verified_at' => now(),
]);
$tempTenant = Tenant::create([
'user_id' => $tempUser->id,
'name' => 'Legacy Tenant ' . $purchase->id,
'status' => 'active',
]);
$purchase->update([
'user_id' => $tempUser->id,
'tenant_id' => $tempTenant->id,
]);
// Assign default free package
TenantPackage::create([
'tenant_id' => $tempTenant->id,
'package_id' => 1, // Assume free package ID 1
'expires_at' => now()->addYear(),
'is_active' => true,
]);
$this->info("Created temp user/tenant for purchase {$purchase->id}");
} else {
$user = User::find($purchase->user_id);
if ($user && $user->tenant) {
$purchase->update(['tenant_id' => $user->tenant->id]);
$this->info("Assigned tenant for purchase {$purchase->id}");
} else {
$this->error("Could not assign tenant for purchase {$purchase->id}");
}
}
}
$this->info('Legacy migration completed.');
return 0;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum PackageType: string
{
case ENDCUSTOMER = 'endcustomer';
case RESELLER = 'reseller';
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Pages\Auth;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class Login extends Page implements HasForms
{
use InteractsWithForms;
protected string $view = 'filament.pages.auth.login';
public function getFormSchema(): array
{
return [
TextInput::make('data.username_or_email')
->label('Username or Email')
->required()
->autofocus(),
TextInput::make('data.password')
->password()
->required()
->extraAttributes(['tabindex' => 2]),
Checkbox::make('data.remember')
->label('Remember me'),
];
}
public function submit(): void
{
$data = $this->form->getState();
$credentials = $this->getCredentialsFromFormData($data);
if (! Auth::attempt($credentials, $data['remember'] ?? false)) {
throw ValidationException::withMessages([
'data.username_or_email' => __('auth.failed'),
]);
}
$user = Auth::user();
if (! $user->email_verified_at) {
Auth::logout();
throw ValidationException::withMessages([
'data.username_or_email' => 'Your email address is not verified. Please check your email for a verification link.',
]);
}
if (! $user->tenant) {
Auth::logout();
throw ValidationException::withMessages([
'data.username_or_email' => 'No tenant associated with your account. Contact support.',
]);
}
session()->regenerate();
$this->redirect($this->getRedirectUrl());
}
protected function getCredentialsFromFormData(array $data): array
{
$usernameOrEmail = $data['username_or_email'];
$password = $data['password'];
$credentials = ['password' => $password];
if (filter_var($usernameOrEmail, FILTER_VALIDATE_EMAIL)) {
$credentials['email'] = $usernameOrEmail;
} else {
$credentials['username'] = $usernameOrEmail;
}
return $credentials;
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Filament\Pages;
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Schema;
class SuperAdminProfile extends BaseEditProfile
{
protected function getUsernameFormComponent(): Component
{
return TextInput::make('username')
->label(__('Username'))
->maxLength(32)
->unique(ignoreRecord: true);
}
protected function getPreferredLocaleFormComponent(): Component
{
$supported = collect(explode(',', (string) env('APP_SUPPORTED_LOCALES', 'de,en')))
->map(fn ($l) => trim((string) $l))
->filter()
->unique()
->values()
->all();
if (empty($supported)) {
$supported = array_values(array_unique(array_filter([
config('app.locale'),
config('app.fallback_locale'),
])));
}
$options = collect($supported)->mapWithKeys(fn ($l) => [$l => strtoupper($l)])->all();
return Select::make('preferred_locale')
->label(__('Language'))
->required()
->options($options);
}
public function form(Schema $schema): Schema
{
return $schema
->components([
$this->getNameFormComponent(),
$this->getEmailFormComponent(),
$this->getUsernameFormComponent(),
$this->getPreferredLocaleFormComponent(),
$this->getPasswordFormComponent(),
$this->getPasswordConfirmationFormComponent(),
$this->getCurrentPasswordFormComponent(),
]);
}
}

View File

@@ -32,18 +32,11 @@ class EventPurchaseResource extends Resource
{
protected static ?string $model = EventPurchase::class;
public static function getNavigationIcon(): string
public static function shouldRegisterNavigation(): bool
{
return 'heroicon-o-shopping-cart';
return false;
}
public static function getNavigationGroup(): string
{
return 'Billing';
}
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PackageResource\Pages;
use App\Filament\Resources\PackageResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePackage extends CreateRecord
{
protected static string $resource = PackageResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\PackageResource\Pages;
use App\Filament\Resources\PackageResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPackage extends EditRecord
{
protected static string $resource = PackageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

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

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PurchaseResource\Pages;
use App\Models\PackagePurchase;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Log;
use BackedEnum;
use UnitEnum;
class PurchaseResource extends Resource
{
protected static ?string $model = PackagePurchase::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shopping-cart';
public static function getNavigationGroup(): string
{
return 'Billing';
}
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Select::make('tenant_id')
->label('Tenant')
->relationship('tenant', 'name')
->searchable()
->preload()
->nullable(),
Select::make('event_id')
->label('Event')
->relationship('event', 'name')
->searchable()
->preload()
->nullable(),
Select::make('package_id')
->label('Package')
->relationship('package', 'name')
->searchable()
->preload()
->required(),
TextInput::make('provider_id')
->label('Provider ID')
->required()
->maxLength(255),
TextInput::make('price')
->label('Price')
->numeric()
->step(0.01)
->prefix('€')
->required(),
Select::make('type')
->label('Type')
->options([
'endcustomer_event' => 'Endcustomer Event',
'reseller_subscription' => 'Reseller Subscription',
])
->required(),
Textarea::make('metadata')
->label('Metadata')
->json()
->columnSpanFull(),
Toggle::make('refunded')
->label('Refunded')
->default(false),
])
->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
BadgeColumn::make('type')
->label('Type')
->color(fn (string $state): string => match($state) {
'endcustomer_event' => 'info',
'reseller_subscription' => 'success',
default => 'gray',
}),
TextColumn::make('tenant.name')
->label('Tenant')
->searchable()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('event.name')
->label('Event')
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('package.name')
->label('Package')
->badge()
->color('success'),
TextColumn::make('price')
->label('Price')
->money('EUR')
->sortable(),
TextColumn::make('purchased_at')
->dateTime()
->sortable(),
BadgeColumn::make('refunded')
->label('Status')
->color(fn (bool $state): string => $state ? 'danger' : 'success'),
TextColumn::make('provider_id')
->copyable()
->toggleable(),
])
->filters([
SelectFilter::make('type')
->options([
'endcustomer_event' => 'Endcustomer Event',
'reseller_subscription' => 'Reseller Subscription',
]),
Filter::make('purchased_at')
->form([
DateTimePicker::make('started_from'),
DateTimePicker::make('ended_before'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['started_from'],
fn (Builder $query, $date): Builder => $query->whereDate('purchased_at', '>=', $date),
)
->when(
$data['ended_before'],
fn (Builder $query, $date): Builder => $query->whereDate('purchased_at', '<=', $date),
);
}),
SelectFilter::make('tenant_id')
->label('Tenant')
->relationship('tenant', 'name')
->searchable(),
])
->actions([
ViewAction::make(),
EditAction::make(),
Action::make('refund')
->label('Refund')
->color('danger')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (PackagePurchase $record): bool => !$record->refunded)
->action(function (PackagePurchase $record) {
$record->update(['refunded' => true]);
// TODO: Call Stripe/PayPal API for actual refund
Log::info('Refund processed for purchase ID: ' . $record->id);
}),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->emptyStateHeading('No Purchases Found')
->emptyStateDescription('Create your first purchase.');
}
public static function getPages(): array
{
return [
'index' => Pages\ListPurchases::route('/'),
'create' => Pages\CreatePurchase::route('/create'),
'view' => Pages\ViewPurchase::route('/{record}'),
'edit' => Pages\EditPurchase::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
// Add RelationManagers if needed
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\PurchaseResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePurchase extends CreateRecord
{
protected static string $resource = PurchaseResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\PurchaseResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPurchase extends EditRecord
{
protected static string $resource = PurchaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

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

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\PurchaseResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewPurchase extends ViewRecord
{
protected static string $resource = PurchaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
Actions\DeleteAction::make(),
Actions\Action::make('refund')
->label('Refund')
->color('danger')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn ($record): bool => !$record->refunded)
->action(function ($record) {
$record->update(['refunded' => true]);
// TODO: Call Stripe/PayPal API for actual refund
}),
];
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TenantPackageResource\Pages;
use App\Models\TenantPackage;
use Filament\Schemas\Schema;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Icons\Icon;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
use BackedEnum;
class TenantPackageResource extends Resource
{
protected static ?string $model = TenantPackage::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shopping-bag';
protected static ?string $navigationLabel = 'Packages';
protected static ?string $slug = 'tenant-packages';
public static function form(Schema $form): Schema
{
return $form
->schema([
Section::make('Package Details')
->schema([
Select::make('package_id')
->relationship('package', 'name')
->required()
->searchable(),
Select::make('tenant_id')
->relationship('tenant', 'name')
->required()
->default(fn () => Auth::user()->tenant_id)
->disabled(),
DateTimePicker::make('expires_at')
->required(),
Toggle::make('is_active')
->default(true),
])
->columns(1),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('package.name')
->searchable()
->sortable(),
TextColumn::make('tenant.name')
->badge()
->color('success'),
TextColumn::make('expires_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
IconColumn::make('is_active')
->boolean(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
ActionGroup::make([
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
]),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Auth::user()->tenant_id));
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenantPackages::route('/'),
'create' => Pages\CreateTenantPackage::route('/create'),
'view' => Pages\ViewTenantPackage::route('/{record}'),
'edit' => Pages\EditTenantPackage::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
class CreateTenantPackage extends CreateRecord
{
protected static string $resource = TenantPackageResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['tenant_id'] = Auth::user()->tenant_id;
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTenantPackage extends EditRecord
{
protected static string $resource = TenantPackageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
class ListTenantPackages extends ListRecords
{
protected static string $resource = TenantPackageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
protected function getTableQuery(): Builder
{
return parent::getTableQuery()->where('tenant_id', Auth::user()->tenant_id);
}
}

View File

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

View File

@@ -38,6 +38,8 @@ class TenantResource extends Resource
public static function form(Schema $form): Schema
{
\Illuminate\Support\Facades\Log::info('TenantResource form() method called');
return $form->schema([
TextInput::make('name')
->label(__('admin.tenants.fields.name'))
@@ -85,22 +87,25 @@ class TenantResource extends Resource
public static function table(Table $table): Table
{
\Illuminate\Support\Facades\Log::info('TenantResource table() method called');
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('activeResellerPackage.name')
Tables\Columns\TextColumn::make('active_reseller_package_id')
->label(__('admin.tenants.fields.active_package'))
->badge()
->color('success'),
Tables\Columns\TextColumn::make('remaining_events')
->color('success')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->name ?? 'Kein aktives Package'),
Tables\Columns\TextColumn::make('active_reseller_package_id')
->label(__('admin.tenants.fields.remaining_events'))
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->remaining_events ?? 0),
Tables\Columns\TextColumn::make('activeResellerPackage.expires_at')
Tables\Columns\TextColumn::make('active_reseller_package_id')
->dateTime()
->label(__('admin.tenants.fields.package_expires_at'))
->badge()
@@ -178,6 +183,8 @@ class TenantResource extends Resource
public static function getRelations(): array
{
\Illuminate\Support\Facades\Log::info('TenantResource getRelations() method called');
return [
TenantPackagesRelationManager::class,
PackagePurchasesRelationManager::class,

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Models\User;
use Filament\Schemas\Schema;
use Filament\Forms\Form;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Icons\Icon;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Request;
use BackedEnum;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-user-circle';
protected static ?string $navigationLabel = 'Users';
protected static ?string $slug = 'users';
public static function form(Schema $form): Schema
{
return $form
->schema([
Section::make('Personal Information')
->schema([
TextInput::make('first_name')
->required()
->maxLength(255),
TextInput::make('last_name')
->required()
->maxLength(255),
TextInput::make('username')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
TextInput::make('email')
->email()
->required()
->unique(ignoreRecord: true),
Textarea::make('address')
->required()
->rows(3),
TextInput::make('phone')
->required()
->tel(),
])
->columns(2),
Section::make('Password')
->schema([
TextInput::make('password')
->password()
->required(fn (string $operation): bool => $operation === 'create')
->dehydrated(fn (?string $state): bool => filled($state))
->same('password_confirmation'),
TextInput::make('password_confirmation')
->password()
->required(fn (string $operation): bool => $operation === 'create')
->dehydrated(false),
])
->columns(1)
->visible(fn (): bool => Auth::user()?->id === Request::route('record')),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('fullName')
->searchable(),
TextColumn::make('email')
->searchable(),
TextColumn::make('username')
->searchable(),
TextColumn::make('phone'),
TextColumn::make('tenant.name')
->label('Tenant'),
TextColumn::make('email_verified_at')
->dateTime()
->sortable(),
])
->filters([
//
])
->actions([
ActionGroup::make([
ViewAction::make(),
EditAction::make(),
]),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Auth::user()->tenant_id));
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Hash;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables;
use Filament\Tables\Table;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
public function table(Table $table): Table
{
return $table
->recordClasses(fn (User $record) => $record->id === auth()->id() ? 'border-2 border-blue-500' : '')
->poll('30s');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Filament\SuperAdmin\Pages\Auth;
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Log;
class EditProfile extends BaseEditProfile
{
public function mount(): void
{
Log::info('EditProfile class loaded for superadmin');
parent::mount();
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
$this->getNameFormComponent(),
$this->getEmailFormComponent(),
TextInput::make('username')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('preferred_locale')
->options([
'de' => 'Deutsch',
'en' => 'English',
])
->default('de')
->required(),
$this->getPasswordFormComponent(),
$this->getPasswordConfirmationFormComponent(),
$this->getCurrentPasswordFormComponent(),
]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Filament\SuperAdmin\Pages\Auth;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class Login extends BaseLogin implements HasForms
{
use InteractsWithForms;
public function authenticate(): ?LoginResponse
{
$data = $this->form->getState();
$credentials = $this->getCredentialsFromFormData($data);
if (! Auth::attempt($credentials, $data['remember'] ?? false)) {
throw ValidationException::withMessages([
'data.email' => __('auth.failed'),
]);
}
$user = Auth::user();
if (! $user->email_verified_at) {
Auth::logout();
throw ValidationException::withMessages([
'data.email' => 'Your email address is not verified. Please check your email for a verification link.',
]);
}
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
if ($user->role !== 'superadmin') {
Auth::logout();
throw ValidationException::withMessages([
'data.email' => 'You do not have access to the SuperAdmin panel. Contact support.',
]);
}
session()->regenerate();
return $this->getLoginResponse();
}
protected function getCredentialsFromFormData(array $data): array
{
return [
'email' => $data['email'],
'password' => $data['password'],
];
}
public function getFormSchema(): array
{
return [
TextInput::make('data.email')
->label('Email')
->email()
->required()
->autofocus(),
TextInput::make('data.password')
->label('Password')
->password()
->required()
->extraAttributes(['tabindex' => 2]),
Checkbox::make('data.remember')
->label('Remember me'),
];
}
}

View File

@@ -53,11 +53,7 @@ class EventController extends Controller
$tenant = Tenant::findOrFail($tenantId);
}
if (!$tenant->canCreateEvent()) {
return response()->json([
'error' => 'No available package for creating events. Please purchase a package.',
], 402);
}
// Package check is now handled by middleware
$validated = $request->validated();
$tenantId = $tenant->id;

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\User;
use App\Models\Tenant;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Support\Str;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class MarketingRegisterController extends Controller
{
/**
* Show the registration form.
*/
public function create(Request $request): View
{
$package = null;
if ($request->has('package_id')) {
$package = Package::findOrFail($request->package_id);
}
return view('marketing.register', compact('package'));
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:' . User::class, 'alpha_dash'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:' . User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'address' => ['required', 'string'],
'phone' => ['required', 'string', 'max:20'],
'privacy_consent' => ['required', 'accepted'],
'package_id' => ['nullable', 'exists:packages,id'],
]);
$user = User::create([
'name' => $request->name,
'username' => $request->username,
'email' => $request->email,
'password' => Hash::make($request->password),
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'address' => $request->address,
'phone' => $request->phone,
'preferred_locale' => $request->preferred_locale ?? 'de',
]);
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $request->name,
'slug' => Str::slug($request->name . '-' . now()->timestamp),
'email' => $request->email,
]);
// If package_id provided and free, assign immediately
if ($request->package_id) {
$package = Package::find($request->package_id);
if ($package && $package->price == 0) {
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'active' => true,
'expires_at' => now()->addYear(), // or based on package duration
]);
}
}
event(new Registered($user));
Auth::login($user);
return $user->hasVerifiedEmail()
? redirect()->intended('/admin')
: redirect()->route('verification.notice');
}
}

View File

@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
use App\Models\Tenant;
use Illuminate\Support\Str;
class RegisteredUserController extends Controller
{
@@ -42,6 +44,13 @@ class RegisteredUserController extends Controller
'password' => Hash::make($request->password),
]);
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $request->name,
'slug' => Str::slug($request->name . '-' . now()->timestamp),
'email' => $request->email,
]);
event(new Registered($user));
Auth::login($user);

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Stripe\Stripe;
use Stripe\Checkout\Session;
@@ -18,12 +19,16 @@ use PayPal\Rest\ApiContext;
use PayPal\Auth\OAuthTokenCredential;
use App\Models\Tenant;
use App\Models\EventPurchase;
use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\PackagePurchase;
use Illuminate\Support\Facades\Auth;
class MarketingController extends Controller
{
public function __construct()
{
\Stripe\Stripe::setApiKey(config('services.stripe.key'));
Stripe::setApiKey(config('services.stripe.key'));
}
public function index()
@@ -53,74 +58,155 @@ class MarketingController extends Controller
return redirect()->back()->with('success', 'Nachricht gesendet!');
}
public function checkout(Request $request, $package)
/**
* Handle package purchase flow.
*/
public function buyPackages(Request $request, $packageId)
{
$packages = [
'basic' => ['name' => 'Basic', 'price' => 0, 'events' => 1],
'standard' => ['name' => 'Standard', 'price' => 9900, 'events' => 10], // cents
'premium' => ['name' => 'Premium', 'price' => 19900, 'events' => 50],
];
$package = Package::findOrFail($packageId);
if (!isset($packages[$package])) {
abort(404);
if (!Auth::check()) {
return redirect()->route('register', ['package_id' => $package->id])
->with('message', __('marketing.packages.register_required'));
}
$pkg = $packages[$package];
$user = Auth::user();
if (!$user->email_verified_at) {
return redirect()->route('verification.notice')
->with('message', __('auth.verification_required'));
}
if ($pkg['price'] == 0) {
// Free package: create tenant and event
$tenant = Tenant::create([
'name' => $request->input('tenant_name', 'New Tenant'),
'slug' => Str::slug('new-' . now()),
'email' => $request->input('email'),
'events_remaining' => $pkg['events'],
]);
$tenant = $user->tenant;
if (!$tenant) {
abort(500, 'Tenant not found');
}
// Create initial event
$event = $tenant->events()->create([
'name' => $request->input('event_name', 'My Event'),
'slug' => Str::slug($request->input('event_name', 'my-event')),
'status' => 'active',
]);
if ($package->price == 0) {
TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'active' => true,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
]
);
$purchase = EventPurchase::create([
PackagePurchase::create([
'tenant_id' => $tenant->id,
'events_purchased' => $pkg['events'],
'amount' => 0,
'currency' => 'EUR',
'provider' => 'free',
'status' => 'completed',
'package_id' => $package->id,
'provider_id' => 'free',
'price' => 0,
'type' => $package->type,
'purchased_at' => now(),
'refunded' => false,
]);
return redirect("/admin/tenants/{$tenant->id}/edit")->with('success', 'Konto erstellt! Willkommen bei Fotospiel.');
return redirect('/admin')->with('success', __('marketing.packages.free_assigned'));
}
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
if ($request->input('provider') === 'paypal') {
return $this->paypalCheckout($request, $packageId);
}
return $this->checkout($request, $packageId);
}
/**
* Checkout for Stripe with auth metadata.
*/
public function checkout(Request $request, $packageId)
{
$package = Package::findOrFail($packageId);
$user = Auth::user();
$tenant = $user->tenant;
$stripe = new StripeClient(config('services.stripe.secret'));
$session = $stripe->checkout->sessions->create([
'payment_method_types' => ['card'],
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => $pkg['name'] . ' Package',
'name' => $package->name,
],
'unit_amount' => $pkg['price'],
'unit_amount' => $package->price * 100,
],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('marketing.success', $package),
'cancel_url' => route('marketing'),
'success_url' => route('marketing.success', $packageId),
'cancel_url' => route('packages'),
'metadata' => [
'package' => $package,
'events' => $pkg['events'],
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
],
]);
return redirect($session->url, 303);
}
/**
* PayPal checkout with auth metadata.
*/
public function paypalCheckout(Request $request, $packageId)
{
$package = Package::findOrFail($packageId);
$user = Auth::user();
$tenant = $user->tenant;
$apiContext = new ApiContext(
new OAuthTokenCredential(
config('services.paypal.client_id'),
config('services.paypal.secret')
)
);
$payment = new Payment();
$payer = new Payer();
$payer->setPaymentMethod('paypal');
$amountObj = new Amount();
$amountObj->setCurrency('EUR');
$amountObj->setTotal($package->price);
$transaction = new Transaction();
$transaction->setAmount($amountObj);
$redirectUrls = new RedirectUrls();
$redirectUrls->setReturnUrl(route('marketing.success', $packageId));
$redirectUrls->setCancelUrl(route('packages'));
$customData = json_encode([
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
]);
$payment->setIntent('sale')
->setPayer($payer)
->setTransactions([$transaction])
->setRedirectUrls($redirectUrls)
->setNoteToPayer('Package: ' . $package->name)
->setCustom($customData);
try {
$payment->create($apiContext);
session(['paypal_payment_id' => $payment->getId()]);
return redirect($payment->getApprovalLink());
} catch (\Exception $e) {
Log::error('PayPal checkout error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
}
}
public function stripeCheckout($sessionId)
{
// Handle Stripe success
@@ -154,82 +240,4 @@ class MarketingController extends Controller
return view('marketing.blog-show', compact('post'));
}
public function paypalCheckout(Request $request, $package)
{
$packages = [
'basic' => ['name' => 'Basic', 'price' => 0, 'events' => 1],
'standard' => ['name' => 'Standard', 'price' => 99, 'events' => 10],
'premium' => ['name' => 'Premium', 'price' => 199, 'events' => 50],
];
if (!isset($packages[$package])) {
abort(404);
}
$pkg = $packages[$package];
if ($pkg['price'] == 0) {
// Free package: create tenant and event
$tenant = Tenant::create([
'name' => $request->input('tenant_name', 'New Tenant'),
'slug' => Str::slug('new-' . now()),
'email' => $request->input('email'),
'events_remaining' => $pkg['events'],
]);
// Create initial event
$event = $tenant->events()->create([
'name' => $request->input('event_name', 'My Event'),
'slug' => Str::slug($request->input('event_name', 'my-event')),
'status' => 'active',
]);
$purchase = EventPurchase::create([
'tenant_id' => $tenant->id,
'events_purchased' => $pkg['events'],
'amount' => 0,
'currency' => 'EUR',
'provider' => 'free',
'status' => 'completed',
'purchased_at' => now(),
]);
return redirect("/admin/tenants/{$tenant->id}/edit")->with('success', 'Konto erstellt! Willkommen bei Fotospiel.');
}
$apiContext = new ApiContext(
new OAuthTokenCredential(
config('services.paypal.client_id'),
config('services.paypal.secret')
)
);
$payment = new Payment();
$payer = new Payer();
$payer->setPaymentMethod('paypal');
$amountObj = new Amount();
$amountObj->setCurrency('EUR');
$amountObj->setTotal($pkg['price']);
$transaction = new Transaction();
$transaction->setAmount($amountObj);
$redirectUrls = new RedirectUrls();
$redirectUrls->setReturnUrl(route('marketing.success', $package));
$redirectUrls->setCancelUrl(route('marketing'));
$payment->setIntent('sale')
->setPayer($payer)
->setTransactions([$transaction])
->setRedirectUrls($redirectUrls);
try {
$payment->create($apiContext);
return redirect($payment->getApprovalLink());
} catch (Exception $e) {
return back()->with('error', 'Zahlung fehlgeschlagen');
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\Package;
use PaypalServerSdkLib\PaypalServerSdkClientBuilder;
use PaypalServerSdkLib\Auth\ClientCredentialsAuthCredentialsBuilder;
use PaypalServerSdkLib\Environment;
use PaypalServerSdkLib\Logging\LoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\RequestLoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\ResponseLoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\LogLevel;
use PaypalServerSdkLib\Orders\OrderRequestBuilder;
use PaypalServerSdkLib\Orders\CheckoutPaymentIntent;
use PaypalServerSdkLib\Orders\PurchaseUnitRequestBuilder;
use PaypalServerSdkLib\Orders\AmountWithBreakdownBuilder;
use PaypalServerSdkLib\Orders\ApplicationContextBuilder;
use PaypalServerSdkLib\Subscriptions\SubscriptionRequestBuilder;
use PaypalServerSdkLib\Subscriptions\SubscriberBuilder;
use PaypalServerSdkLib\Subscriptions\NameBuilder;
use PaypalServerSdkLib\Subscriptions\ApplicationContextSubscriptionBuilder;
use PaypalServerSdkLib\Subscriptions\ShippingPreference;
class PayPalController extends Controller
{
private $client;
public function __construct()
{
$clientId = config('services.paypal.client_id');
$clientSecret = config('services.paypal.secret');
$this->client = PaypalServerSdkClientBuilder::init()
->clientCredentialsAuthCredentials(
ClientCredentialsAuthCredentialsBuilder::init($clientId, $clientSecret)
)
->environment(config('app.env') === 'production' ? Environment::PRODUCTION : Environment::SANDBOX)
->loggingConfiguration(
LoggingConfigurationBuilder::init()
->level(LogLevel::INFO)
->requestConfiguration(RequestLoggingConfigurationBuilder::init()->body(true))
->responseConfiguration(ResponseLoggingConfigurationBuilder::init()->headers(true))
)
->build();
}
public function createOrder(Request $request)
{
$request->validate([
'tenant_id' => 'required|exists:tenants,id',
'package_id' => 'required|exists:packages,id',
]);
$tenant = Tenant::findOrFail($request->tenant_id);
$package = Package::findOrFail($request->package_id);
$ordersController = $this->client->getOrdersController();
$requestBody = OrderRequestBuilder::init(CheckoutPaymentIntent::CAPTURE)
->purchaseUnits([
PurchaseUnitRequestBuilder::init()
->amount(
AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', ''))
->build()
)
->description('Package: ' . $package->name)
->customId($tenant->id . '_' . $package->id . '_endcustomer_event')
->build()
])
->applicationContext(
ApplicationContextBuilder::init()
->shippingPreference(ShippingPreference::NO_SHIPPING)
->userAction('PAY_NOW')
->build()
)
->build();
$collect = [
'body' => $requestBody,
'prefer' => 'return=representation'
];
try {
$response = $ordersController->createOrder($collect);
if ($response->statusCode === 201) {
$result = $response->result;
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
return response()->json([
'id' => $result->id,
'approve_url' => $approveLink,
]);
}
Log::error('PayPal order creation failed', ['response' => $response]);
return response()->json(['error' => 'Order creation failed'], 400);
} catch (\Exception $e) {
Log::error('PayPal order creation exception', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Order creation failed'], 500);
}
}
public function captureOrder(Request $request)
{
$request->validate(['order_id' => 'required']);
$ordersController = $this->client->getOrdersController();
$collect = [
'id' => $request->order_id,
'prefer' => 'return=representation'
];
try {
$response = $ordersController->captureOrder($collect);
if ($response->statusCode === 201) {
$result = $response->result;
$customId = $result->purchaseUnits[0]->customId ?? null;
if ($customId) {
[$tenantId, $packageId, $type] = explode('_', $customId);
$tenant = Tenant::findOrFail($tenantId);
$package = Package::findOrFail($packageId);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $result->id,
'price' => $result->purchaseUnits[0]->amount->value,
'type' => $type ?? 'endcustomer_event',
'purchased_at' => now(),
'refunded' => false,
]);
Log::info('PayPal order captured and purchase created: ' . $result->id);
}
return response()->json(['status' => 'captured', 'order' => $result]);
}
Log::error('PayPal order capture failed', ['response' => $response]);
return response()->json(['error' => 'Capture failed'], 400);
} catch (\Exception $e) {
Log::error('PayPal order capture exception', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Capture failed'], 500);
}
}
public function createSubscription(Request $request)
{
$request->validate([
'tenant_id' => 'required|exists:tenants,id',
'package_id' => 'required|exists:packages,id',
'plan_id' => 'required', // PayPal plan ID for the package
]);
$tenant = Tenant::findOrFail($request->tenant_id);
$package = Package::findOrFail($request->package_id);
$subscriptionsController = $this->client->getSubscriptionsController();
$requestBody = SubscriptionRequestBuilder::init()
->planId($request->plan_id)
->subscriber(
SubscriberBuilder::init()
->name(
NameBuilder::init()
->givenName($tenant->name ?? 'Tenant')
->build()
)
->emailAddress($tenant->email)
->build()
)
->customId($tenant->id . '_' . $package->id . '_reseller_subscription')
->applicationContext(
ApplicationContextSubscriptionBuilder::init()
->shippingPreference(ShippingPreference::NO_SHIPPING)
->userAction('SUBSCRIBE_NOW')
->build()
)
->build();
$collect = [
'body' => $requestBody,
'prefer' => 'return=representation'
];
try {
$response = $subscriptionsController->createSubscription($collect);
if ($response->statusCode === 201) {
$result = $response->result;
$subscriptionId = $result->id;
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => true,
]);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $subscriptionId,
'price' => $package->price,
'type' => 'reseller_subscription',
'purchased_at' => now(),
]);
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
return response()->json([
'subscription_id' => $subscriptionId,
'approve_url' => $approveLink,
]);
}
Log::error('PayPal subscription creation failed', ['response' => $response]);
return response()->json(['error' => 'Subscription creation failed'], 400);
} catch (\Exception $e) {
Log::error('PayPal subscription creation exception', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Subscription creation failed'], 500);
}
}
}

View File

@@ -16,32 +16,60 @@ class PayPalWebhookController extends Controller
$payerEmail = $input['payer_email'] ?? null;
$paymentStatus = $input['payment_status'] ?? null;
$mcGross = $input['mc_gross'] ?? 0;
$packageId = $input['custom'] ?? null;
$custom = $input['custom'] ?? null;
if ($paymentStatus === 'Completed' && $mcGross > 0) {
// Verify IPN with PayPal (simplified; use SDK for full verification)
// $verified = $this->verifyIPN($input);
// Find or create tenant (for public checkout, perhaps create new or use session)
// For now, assume tenant_id from custom or session
$tenantId = $packageId ? Tenant::where('slug', $packageId)->first()->id ?? 1 : 1;
// Parse custom for user_id or tenant_id
$data = json_decode($custom, true);
$userId = $data['user_id'] ?? null;
$tenantId = $data['tenant_id'] ?? null;
$packageId = $data['package_id'] ?? null;
// Create purchase and increment credits
$purchase = EventPurchase::create([
'tenant_id' => $tenantId, // Implement tenant resolution
'events_purchased' => $mcGross / 49, // Example: 49€ per event credit
'amount' => $mcGross,
'currency' => $input['mc_currency'] ?? 'EUR',
'provider' => 'paypal',
'external_receipt_id' => $ipnMessage,
'status' => 'completed',
if ($userId && !$tenantId) {
$tenant = \App\Models\Tenant::where('user_id', $userId)->first();
if ($tenant) {
$tenantId = $tenant->id;
} else {
Log::error('Tenant not found for user_id in PayPal IPN: ' . $userId);
return response('OK', 200);
}
}
if (!$tenantId || !$packageId) {
Log::error('Missing tenant or package in PayPal IPN custom data');
return response('OK', 200);
}
// Create PackagePurchase
\App\Models\PackagePurchase::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'provider_id' => $ipnMessage,
'price' => $mcGross,
'type' => $data['type'] ?? 'reseller_subscription',
'purchased_at' => now(),
'refunded' => false,
]);
$tenant = Tenant::find($tenantId);
$tenant->incrementCredits($purchase->events_purchased, 'paypal_purchase', 'PayPal IPN', $purchase->id);
// Update TenantPackage if subscription
if ($data['type'] ?? '' === 'reseller_subscription') {
\App\Models\TenantPackage::updateOrCreate(
[
'tenant_id' => $tenantId,
'package_id' => $packageId,
],
[
'active' => true,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
]
);
}
Log::info('PayPal IPN processed', $input);
Log::info('PayPal IPN processed for tenant ' . $tenantId . ', package ' . $packageId, $input);
}
return response('OK', 200);

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(Request $request, User $user): RedirectResponse
{
// Authorized via auth middleware
$request->validate([
'name' => 'required|string|max:255',
'username' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:users,username,' . $user->id],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id],
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'address' => ['required', 'string'],
'phone' => ['required', 'string', 'max:20'],
]);
$user->update($request->only([
'name', 'username', 'email', 'first_name', 'last_name', 'address', 'phone'
]));
return back()->with('status', 'profile-updated');
}
/**
* Update the user's password.
*/
public function updatePassword(Request $request, User $user): RedirectResponse
{
// Authorized via auth middleware
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user->update([
'password' => Hash::make($request->password),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\PaymentIntent;
use Stripe\Subscription;
class StripeController extends Controller
{
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function createPaymentIntent(Request $request)
{
$request->validate([
'tenant_id' => 'required|exists:tenants,id',
'package_id' => 'required|exists:packages,id',
'amount' => 'required|numeric|min:0',
]);
$tenant = Tenant::findOrFail($request->tenant_id);
$package = \App\Models\Package::findOrFail($request->package_id);
$paymentIntent = PaymentIntent::create([
'amount' => $request->amount * 100, // cents
'currency' => 'eur',
'metadata' => [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => 'endcustomer_event', // or reseller
],
]);
return response()->json([
'client_secret' => $paymentIntent->client_secret,
]);
}
public function createSubscription(Request $request)
{
$request->validate([
'tenant_id' => 'required|exists:tenants,id',
'package_id' => 'required|exists:packages,id',
'payment_method_id' => 'required',
]);
$tenant = Tenant::findOrFail($request->tenant_id);
$package = \App\Models\Package::findOrFail($request->package_id);
$subscription = Subscription::create([
'customer' => $tenant->stripe_customer_id ?? $this->createCustomer($tenant),
'items' => [[
'price' => $package->stripe_price_id, // Assume package has stripe_price_id
]],
'payment_method' => $request->payment_method_id,
'default_payment_method' => $request->payment_method_id,
'expand' => ['latest_invoice.payment_intent'],
'metadata' => [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => 'reseller_subscription',
],
]);
// Create TenantPackage and PackagePurchase
\App\Models\TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => true,
]);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $subscription->id,
'price' => $package->price,
'type' => 'reseller_subscription',
'purchased_at' => now(),
]);
return response()->json([
'subscription_id' => $subscription->id,
'client_secret' => $subscription->latest_invoice->payment_intent->client_secret ?? null,
]);
}
private function createCustomer(Tenant $tenant)
{
$customer = \Stripe\Customer::create([
'email' => $tenant->email,
'metadata' => ['tenant_id' => $tenant->id],
]);
$tenant->update(['stripe_customer_id' => $customer->id]);
return $customer->id;
}
}

View File

@@ -2,90 +2,169 @@
namespace App\Http\Controllers;
use App\Models\EventPurchase;
use App\Models\Tenant;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function handleWebhook(Request $request)
{
$payload = $request->getContent();
$sig = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook');
$sigHeader = $request->header('Stripe-Signature');
$endpointSecret = config('services.stripe.webhook_secret');
if (!$secret || !$sig) {
abort(400, 'Missing signature');
try {
$event = Webhook::constructEvent($payload, $sigHeader, $endpointSecret);
} catch (\Exception $e) {
Log::error('Stripe webhook signature verification failed: ' . $e->getMessage());
return response()->json(['error' => 'Invalid signature'], 400);
}
$expectedSig = 'v1=' . hash_hmac('sha256', $payload, $secret);
switch ($event['type']) {
case 'payment_intent.succeeded':
$paymentIntent = $event['data']['object'];
$this->handlePaymentIntentSucceeded($paymentIntent);
break;
if (!hash_equals($expectedSig, $sig)) {
abort(400, 'Invalid signature');
case 'invoice.payment_succeeded':
$invoice = $event['data']['object'];
$this->handleInvoicePaymentSucceeded($invoice);
break;
case 'invoice.payment_failed':
$invoice = $event['data']['object'];
$this->handleInvoicePaymentFailed($invoice);
break;
default:
Log::info('Unhandled Stripe event type: ' . $event['type']);
}
$event = json_decode($payload, true);
return response()->json(['status' => 'success']);
}
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error('Invalid JSON in Stripe webhook: ' . json_last_error_msg());
return response('', 200);
private function handlePaymentIntentSucceeded($paymentIntent)
{
$metadata = $paymentIntent['metadata'];
if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) {
Log::warning('Missing metadata in Stripe payment intent: ' . $paymentIntent['id']);
return;
}
if ($event['type'] === 'checkout.session.completed') {
$session = $event['data']['object'];
$receiptId = $session['id'];
$userId = $metadata['user_id'] ?? null;
$tenantId = $metadata['tenant_id'] ?? null;
$packageId = $metadata['package_id'];
$type = $metadata['type'] ?? 'endcustomer_event';
// Idempotency check
if (EventPurchase::where('external_receipt_id', $receiptId)->exists()) {
return response('', 200);
if ($userId && !$tenantId) {
$tenant = \App\Models\Tenant::where('user_id', $userId)->first();
if ($tenant) {
$tenantId = $tenant->id;
} else {
Log::error('Tenant not found for user_id: ' . $userId);
return;
}
$tenantId = $session['metadata']['tenant_id'] ?? null;
if (!$tenantId) {
Log::warning('No tenant_id in Stripe metadata', ['receipt_id' => $receiptId]);
// Dispatch job for retry or manual resolution
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
return response('', 200);
}
$tenant = Tenant::find($tenantId);
if (!$tenant) {
Log::error('Tenant not found for Stripe webhook', ['tenant_id' => $tenantId]);
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
return response('', 200);
}
$amount = $session['amount_total'] / 100;
$currency = $session['currency'];
$eventsPurchased = (int) ($session['metadata']['events_purchased'] ?? 1);
DB::transaction(function () use ($tenant, $amount, $currency, $eventsPurchased, $receiptId) {
$purchase = EventPurchase::create([
'tenant_id' => $tenant->id,
'events_purchased' => $eventsPurchased,
'amount' => $amount,
'currency' => $currency,
'provider' => 'stripe',
'external_receipt_id' => $receiptId,
'status' => 'completed',
'purchased_at' => now(),
]);
$tenant->incrementCredits($eventsPurchased, 'purchase', null, $purchase->id);
});
Log::info('Processed Stripe purchase', ['receipt_id' => $receiptId, 'tenant_id' => $tenantId]);
} else {
// For other event types, log or dispatch job if needed
Log::info('Unhandled Stripe event', ['type' => $event['type']]);
// Optionally dispatch job for processing other events
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
}
return response('', 200);
if (!$tenantId) {
Log::error('No tenant_id found for Stripe payment intent: ' . $paymentIntent['id']);
return;
}
// Create PackagePurchase for one-off payment
\App\Models\PackagePurchase::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'provider_id' => $paymentIntent['id'],
'price' => $paymentIntent['amount_received'] / 100,
'type' => $type,
'purchased_at' => now(),
'refunded' => false,
]);
if ($type === 'endcustomer_event') {
// For event packages, assume event_id from metadata or handle separately
// TODO: Link to specific event if provided
}
Log::info('Package purchase created via Stripe payment intent: ' . $paymentIntent['id'] . ' for tenant ' . $tenantId);
}
private function handleInvoicePaymentSucceeded($invoice)
{
$subscription = $invoice['subscription'];
$metadata = $invoice['metadata'];
if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) {
Log::warning('Missing metadata in Stripe invoice: ' . $invoice['id']);
return;
}
$userId = $metadata['user_id'] ?? null;
$tenantId = $metadata['tenant_id'] ?? null;
$packageId = $metadata['package_id'];
if ($userId && !$tenantId) {
$tenant = \App\Models\Tenant::where('user_id', $userId)->first();
if ($tenant) {
$tenantId = $tenant->id;
} else {
Log::error('Tenant not found for user_id: ' . $userId);
return;
}
}
if (!$tenantId) {
Log::error('No tenant_id found for Stripe invoice: ' . $invoice['id']);
return;
}
// Update or create TenantPackage for subscription
\App\Models\TenantPackage::updateOrCreate(
[
'tenant_id' => $tenantId,
'package_id' => $packageId,
],
[
'purchased_at' => now(),
'expires_at' => now()->addYear(), // Renew annually
'active' => true,
]
);
// Create or update PackagePurchase
\App\Models\PackagePurchase::updateOrCreate(
[
'tenant_id' => $tenantId,
'package_id' => $packageId,
'provider_id' => $subscription,
],
[
'price' => $invoice['amount_paid'] / 100,
'type' => 'reseller_subscription',
'purchased_at' => now(),
'refunded' => false,
]
);
Log::info('Subscription renewed via Stripe invoice: ' . $invoice['id'] . ' for tenant ' . $tenantId);
}
private function handleInvoicePaymentFailed($invoice)
{
$subscription = $invoice['subscription'];
Log::warning('Stripe invoice payment failed: ' . $invoice['id'] . ' for subscription ' . $subscription);
// TODO: Deactivate package or notify tenant
// e.g., TenantPackage::where('provider_id', $subscription)->update(['active' => false]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PackageMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) {
$tenant = $this->resolveTenant($request);
$request->attributes->set('tenant', $tenant);
$request->attributes->set('tenant_id', $tenant->id);
$request->merge([
'tenant' => $tenant,
'tenant_id' => $tenant->id,
]);
}
if ($this->requiresPackageCheck($request) && !$this->canPerformAction($request, $tenant)) {
return response()->json([
'error' => 'Package limits exceeded. Please purchase or upgrade a package.',
], 402);
}
return $next($request);
}
private function requiresPackageCheck(Request $request): bool
{
return $request->isMethod('post') && (
$request->routeIs('api.v1.tenant.events.store') ||
$request->routeIs('api.v1.tenant.photos.store') // Assuming photo upload route
);
}
private function canPerformAction(Request $request, Tenant $tenant): bool
{
if ($request->routeIs('api.v1.tenant.events.store')) {
// Check tenant package for event creation
$resellerPackage = $tenant->activeResellerPackage();
if ($resellerPackage) {
return $resellerPackage->used_events < $resellerPackage->package->max_events_per_year;
}
return false;
}
if ($request->routeIs('api.v1.tenant.photos.store')) {
$eventId = $request->input('event_id');
if (!$eventId) {
return false;
}
$event = Event::findOrFail($eventId);
if ($event->tenant_id !== $tenant->id) {
return false;
}
$eventPackage = $event->eventPackage;
if (!$eventPackage) {
return false;
}
return $eventPackage->used_photos < $eventPackage->package->max_photos;
}
return true;
}
private function resolveTenant(Request $request): Tenant
{
$user = $request->user();
if ($user && isset($user->tenant) && $user->tenant instanceof Tenant) {
return $user->tenant;
}
$tenantId = $request->attributes->get('tenant_id');
if (! $tenantId && $user && isset($user->tenant_id)) {
$tenantId = $user->tenant_id;
}
if (! $tenantId) {
abort(401, 'Unauthenticated');
}
return Tenant::findOrFail($tenantId);
}
}

View File

@@ -26,7 +26,7 @@ class LoginRequest extends FormRequest
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'login' => ['required', 'string'],
'password' => ['required', 'string'],
];
}
@@ -40,11 +40,14 @@ class LoginRequest extends FormRequest
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
$credentials = $this->only('login', 'password');
$credentials['login'] = $this->input('login');
if (! Auth::attempt($credentials, $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
'login' => __('auth.failed'),
]);
}
@@ -79,7 +82,7 @@ class LoginRequest extends FormRequest
*/
public function throttleKey(): string
{
return $this->string('email')
return $this->string('login')
->lower()
->append('|'.$this->ip())
->transliterate()

42
app/Mail/Welcome.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class Welcome extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user)
{
//
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Welcome to Fotospiel!',
);
}
public function content(): Content
{
return new Content(
view: 'emails.welcome',
with: [
'user' => $this->user,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -18,6 +18,8 @@ class Event extends Model
'date' => 'datetime',
'settings' => 'array',
'is_active' => 'boolean',
'name' => 'array',
'description' => 'array',
];
public function tenant(): BelongsTo

View File

@@ -40,13 +40,7 @@ class Package extends Model
'features' => 'array',
];
protected function features(): Attribute
{
return Attribute::make(
get: fn (mixed $value) => $value ? json_decode($value, true) : [],
set: fn (array $value) => json_encode($value),
);
}
// features handled by $casts = ['features' => 'array']
public function eventPackages(): HasMany
{

View File

@@ -78,6 +78,10 @@ class PackagePurchase extends Model
parent::boot();
static::creating(function ($purchase) {
if (!$purchase->tenant_id) {
throw new \Exception('Tenant ID is required for package purchases.');
}
if (!$purchase->purchased_at) {
$purchase->purchased_at = now();
}

View File

@@ -18,6 +18,9 @@ class Task extends Model
protected $casts = [
'due_date' => 'datetime',
'is_completed' => 'bool',
'title' => 'array',
'description' => 'array',
'example_text' => 'array',
];
public function emotion(): BelongsTo

View File

@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\DB;
class Tenant extends Model
@@ -51,9 +53,9 @@ class Tenant extends Model
return $this->hasMany(TenantPackage::class);
}
public function activeResellerPackage()
public function activeResellerPackage(): HasOne
{
return $this->tenantPackages()->where('active', true)->first();
return $this->hasOne(TenantPackage::class)->where('active', true);
}
public function canCreateEvent(): bool
@@ -93,4 +95,9 @@ class Tenant extends Model
get: fn () => $this->activeResellerPackage() !== null,
);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -3,13 +3,15 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
@@ -25,6 +27,10 @@ class User extends Authenticatable
'password',
'username',
'preferred_locale',
'first_name',
'last_name',
'address',
'phone',
];
/**
@@ -50,8 +56,15 @@ class User extends Authenticatable
];
}
public function tenant(): BelongsTo
protected function fullName(): Attribute
{
return $this->belongsTo(Tenant::class);
return Attribute::make(
get: fn () => $this->first_name . ' ' . $this->last_name,
);
}
public function tenant(): HasOne
{
return $this->hasOne(Tenant::class);
}
}

View File

@@ -32,12 +32,12 @@ class AdminPanelProvider extends PanelProvider
->default()
->id('admin')
->path('admin')
->login()
->login(\App\Filament\Pages\Auth\Login::class)
->colors([
'primary' => Color::Pink,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages')
->pages([
Pages\Dashboard::class,
])
@@ -61,8 +61,10 @@ class AdminPanelProvider extends PanelProvider
Authenticate::class,
])
->resources([
// Blog-Resources moved to SuperAdminPanel
\App\Filament\Resources\UserResource::class,
\App\Filament\Resources\TenantPackageResource::class,
])
->tenant(\App\Models\Tenant::class)
// Remove blog models as they are global and handled in SuperAdmin
;
}

View File

@@ -19,7 +19,6 @@ use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use App\Filament\Resources\LegalPageResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Pages\SuperAdminProfile;
use App\Models\User;
use App\Models\Tenant;
use App\Models\BlogPost;
@@ -30,28 +29,31 @@ use App\Filament\Widgets\TopTenantsByUploads;
use Stephenjude\FilamentBlog\Filament\Resources\CategoryResource;
use Stephenjude\FilamentBlog\Filament\Resources\PostResource;
use Stephenjude\FilamentBlog\BlogPlugin;
use Illuminate\Support\Facades\Log;
class SuperAdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
\Illuminate\Support\Facades\Log::info('SuperAdminPanelProvider panel method called');
return $panel
->default()
->id('superadmin')
->path('super-admin')
->login()
->colors([
'primary' => Color::Pink,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->discoverPages(in: app_path('Filament/SuperAdmin/Pages'), for: 'App\\Filament\\SuperAdmin\\Pages')
->pages([
Pages\Dashboard::class,
])
->login(\App\Filament\SuperAdmin\Pages\Auth\Login::class)
->plugin(
BlogPlugin::make()
)
->profile(SuperAdminProfile::class)
->profile()
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
Widgets\AccountWidget::class,
@@ -74,13 +76,10 @@ class SuperAdminPanelProvider extends PanelProvider
Authenticate::class,
])
->resources([
TenantResource::class,
// Temporär deaktiviert: TenantResource - verdächtigt für frühen Fehler
// TenantResource::class,
LegalPageResource::class,
])
->authMiddleware([
Authenticate::class,
'superadmin.auth',
])
->authGuard('web')
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
// Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert

View File

@@ -22,7 +22,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'tenant.token' => TenantTokenGuard::class,
'tenant.isolation' => TenantIsolation::class,
'credit.check' => CreditCheckMiddleware::class,
'package.check' => \App\Http\Middleware\PackageMiddleware::class,
'locale' => \App\Http\Middleware\SetLocale::class,
'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class,
]);

View File

@@ -4,4 +4,5 @@ return [
App\Providers\AppServiceProvider::class,
Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class,
App\Providers\Filament\SuperAdminPanelProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
];

View File

@@ -14,7 +14,7 @@
"laravel/sanctum": "^4.2",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9",
"paypal/rest-api-sdk-php": "^1.6",
"paypal/paypal-server-sdk": "^1.1",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-translatable": "^6.11",
"stephenjude/filament-blog": "*",

885
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cb0adb8c2149ab0ab72bdc3b0b7ee635",
"content-hash": "c7c9c8d3a298a4a78d257a1674cd117d",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -72,6 +72,222 @@
},
"time": "2025-07-30T15:45:57+00:00"
},
{
"name": "apimatic/core",
"version": "0.3.14",
"source": {
"type": "git",
"url": "https://github.com/apimatic/core-lib-php.git",
"reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/apimatic/core-lib-php/zipball/c3eaad6cf0c00b793ce6d9bee8b87176247da582",
"reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582",
"shasum": ""
},
"require": {
"apimatic/core-interfaces": "~0.1.5",
"apimatic/jsonmapper": "^3.1.1",
"ext-curl": "*",
"ext-dom": "*",
"ext-json": "*",
"ext-libxml": "*",
"php": "^7.2 || ^8.0",
"php-jsonpointer/php-jsonpointer": "^3.0.2",
"psr/log": "^1.1.4 || ^2.0.0 || ^3.0.0"
},
"require-dev": {
"phan/phan": "5.4.5",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Core\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Core logic and the utilities for the Apimatic's PHP SDK",
"homepage": "https://github.com/apimatic/core-lib-php",
"keywords": [
"apimatic",
"core",
"corelib",
"php"
],
"support": {
"issues": "https://github.com/apimatic/core-lib-php/issues",
"source": "https://github.com/apimatic/core-lib-php/tree/0.3.14"
},
"time": "2025-02-27T06:03:30+00:00"
},
{
"name": "apimatic/core-interfaces",
"version": "0.1.5",
"source": {
"type": "git",
"url": "https://github.com/apimatic/core-interfaces-php.git",
"reference": "b4f1bffc8be79584836f70af33c65e097eec155c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/apimatic/core-interfaces-php/zipball/b4f1bffc8be79584836f70af33c65e097eec155c",
"reference": "b4f1bffc8be79584836f70af33c65e097eec155c",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"CoreInterfaces\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Definition of the behavior of apimatic/core, apimatic/unirest-php and Apimatic's PHP SDK",
"homepage": "https://github.com/apimatic/core-interfaces-php",
"keywords": [
"apimatic",
"core",
"corelib",
"interface",
"php",
"unirest"
],
"support": {
"issues": "https://github.com/apimatic/core-interfaces-php/issues",
"source": "https://github.com/apimatic/core-interfaces-php/tree/0.1.5"
},
"time": "2024-05-09T06:32:07+00:00"
},
{
"name": "apimatic/jsonmapper",
"version": "3.1.6",
"source": {
"type": "git",
"url": "https://github.com/apimatic/jsonmapper.git",
"reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/apimatic/jsonmapper/zipball/c6cc21bd56bfe5d5822bbd08f514be465c0b24e7",
"reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^5.6 || ^7.0 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"squizlabs/php_codesniffer": "^3.0.0"
},
"type": "library",
"autoload": {
"psr-4": {
"apimatic\\jsonmapper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"OSL-3.0"
],
"authors": [
{
"name": "Christian Weiske",
"email": "christian.weiske@netresearch.de",
"homepage": "http://www.netresearch.de/",
"role": "Developer"
},
{
"name": "Mehdi Jaffery",
"email": "mehdi.jaffery@apimatic.io",
"homepage": "http://apimatic.io/",
"role": "Developer"
}
],
"description": "Map nested JSON structures onto PHP classes",
"support": {
"email": "mehdi.jaffery@apimatic.io",
"issues": "https://github.com/apimatic/jsonmapper/issues",
"source": "https://github.com/apimatic/jsonmapper/tree/3.1.6"
},
"time": "2024-11-28T09:15:32+00:00"
},
{
"name": "apimatic/unirest-php",
"version": "4.0.7",
"source": {
"type": "git",
"url": "https://github.com/apimatic/unirest-php.git",
"reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/apimatic/unirest-php/zipball/bdfd5f27c105772682c88ed671683f1bd93f4a3c",
"reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c",
"shasum": ""
},
"require": {
"apimatic/core-interfaces": "^0.1.0",
"ext-curl": "*",
"ext-json": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phan/phan": "5.4.2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Unirest\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mashape",
"email": "opensource@mashape.com",
"homepage": "https://www.mashape.com",
"role": "Developer"
},
{
"name": "APIMATIC",
"email": "opensource@apimatic.io",
"homepage": "https://www.apimatic.io",
"role": "Developer"
}
],
"description": "Unirest PHP",
"homepage": "https://github.com/apimatic/unirest-php",
"keywords": [
"client",
"curl",
"http",
"https",
"rest"
],
"support": {
"email": "opensource@apimatic.io",
"issues": "https://github.com/apimatic/unirest-php/issues",
"source": "https://github.com/apimatic/unirest-php/tree/4.0.7"
},
"time": "2025-06-17T09:09:48+00:00"
},
{
"name": "bacon/bacon-qr-code",
"version": "2.0.8",
@@ -564,6 +780,83 @@
],
"time": "2024-07-16T11:13:48+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{
"name": "danharrin/date-format-converter",
"version": "v0.3.1",
@@ -1386,6 +1679,43 @@
},
"time": "2025-09-04T14:12:50+00:00"
},
{
"name": "filament/spatie-laravel-media-library-plugin",
"version": "v4.0.7",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git",
"reference": "fc15d6a60a3ff564fbdaf6588c55dab6c931d67a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/spatie-laravel-media-library-plugin/zipball/fc15d6a60a3ff564fbdaf6588c55dab6c931d67a",
"reference": "fc15d6a60a3ff564fbdaf6588c55dab6c931d67a",
"shasum": ""
},
"require": {
"filament/support": "self.version",
"php": "^8.2",
"spatie/laravel-medialibrary": "^11.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Filament\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Filament support for `spatie/laravel-medialibrary`.",
"homepage": "https://github.com/filamentphp/filament",
"support": {
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2025-09-01T09:39:21+00:00"
},
{
"name": "filament/spatie-laravel-tags-plugin",
"version": "v3.3.30",
@@ -3641,6 +3971,84 @@
],
"time": "2025-07-17T05:12:15+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.0",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-07-17T11:15:13+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
@@ -4445,57 +4853,104 @@
"time": "2024-05-08T12:36:18+00:00"
},
{
"name": "paypal/rest-api-sdk-php",
"version": "v1.6.4",
"name": "paypal/paypal-server-sdk",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/paypal/PayPal-PHP-SDK.git",
"reference": "06837d290c4906578cfd92786412dff330a1429c"
"url": "https://github.com/paypal/PayPal-PHP-Server-SDK.git",
"reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paypal/PayPal-PHP-SDK/zipball/06837d290c4906578cfd92786412dff330a1429c",
"reference": "06837d290c4906578cfd92786412dff330a1429c",
"url": "https://api.github.com/repos/paypal/PayPal-PHP-Server-SDK/zipball/3964c1732b1815fa8cf8aee37069ccc4e95d9572",
"reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572",
"shasum": ""
},
"require": {
"apimatic/core": "~0.3.13",
"apimatic/core-interfaces": "~0.1.5",
"apimatic/unirest-php": "^4.0.6",
"ext-curl": "*",
"ext-json": "*",
"php": ">=5.3.0"
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "3.7.*"
"phan/phan": "5.4.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"autoload": {
"psr-0": {
"PayPal": "lib/"
"psr-4": {
"PaypalServerSdkLib\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache2"
"MIT"
],
"description": "PayPal's SDK for interacting with the REST APIs",
"homepage": "https://github.com/paypal/PayPal-PHP-Server-SDK",
"support": {
"issues": "https://github.com/paypal/PayPal-PHP-Server-SDK/issues",
"source": "https://github.com/paypal/PayPal-PHP-Server-SDK/tree/1.1.0"
},
"time": "2025-05-27T17:46:31+00:00"
},
{
"name": "php-jsonpointer/php-jsonpointer",
"version": "v3.0.2",
"source": {
"type": "git",
"url": "https://github.com/raphaelstolt/php-jsonpointer.git",
"reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/raphaelstolt/php-jsonpointer/zipball/4428f86c6f23846e9faa5a420c4ef14e485b3afb",
"reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb",
"shasum": ""
},
"require": {
"php": ">=5.4"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^1.11",
"phpunit/phpunit": "4.6.*"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-0": {
"Rs\\Json": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PayPal",
"homepage": "https://github.com/paypal/rest-api-sdk-php/contributors"
"name": "Raphael Stolt",
"email": "raphael.stolt@gmail.com",
"homepage": "http://raphaelstolt.blogspot.com/"
}
],
"description": "PayPal's PHP SDK for REST APIs",
"homepage": "http://paypal.github.io/PayPal-PHP-SDK/",
"description": "Implementation of JSON Pointer (http://tools.ietf.org/html/rfc6901)",
"homepage": "https://github.com/raphaelstolt/php-jsonpointer",
"keywords": [
"payments",
"paypal",
"rest",
"sdk"
"json",
"json pointer",
"json traversal"
],
"support": {
"issues": "https://github.com/paypal/PayPal-PHP-SDK/issues",
"source": "https://github.com/paypal/PayPal-PHP-SDK/tree/stable"
"issues": "https://github.com/raphaelstolt/php-jsonpointer/issues",
"source": "https://github.com/raphaelstolt/php-jsonpointer/tree/master"
},
"abandoned": "paypal/paypal-server-sdk",
"time": "2016-01-20T17:45:52+00:00"
"time": "2016-08-29T08:51:01+00:00"
},
{
"name": "phpoption/phpoption",
@@ -5677,6 +6132,134 @@
],
"time": "2025-08-25T11:46:57+00:00"
},
{
"name": "spatie/image",
"version": "3.8.6",
"source": {
"type": "git",
"url": "https://github.com/spatie/image.git",
"reference": "0872c5968a7f044fe1e960c26433e54ceaede696"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image/zipball/0872c5968a7f044fe1e960c26433e54ceaede696",
"reference": "0872c5968a7f044fe1e960c26433e54ceaede696",
"shasum": ""
},
"require": {
"ext-exif": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.2",
"spatie/image-optimizer": "^1.7.5",
"spatie/temporary-directory": "^2.2",
"symfony/process": "^6.4|^7.0"
},
"require-dev": {
"ext-gd": "*",
"ext-imagick": "*",
"laravel/sail": "^1.34",
"pestphp/pest": "^2.28",
"phpstan/phpstan": "^1.10.50",
"spatie/pest-plugin-snapshots": "^2.1",
"spatie/pixelmatch-php": "^1.0",
"spatie/ray": "^1.40.1",
"symfony/var-dumper": "^6.4|7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Image\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Manipulate images with an expressive API",
"homepage": "https://github.com/spatie/image",
"keywords": [
"image",
"spatie"
],
"support": {
"source": "https://github.com/spatie/image/tree/3.8.6"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-09-25T12:06:17+00:00"
},
{
"name": "spatie/image-optimizer",
"version": "1.8.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/image-optimizer.git",
"reference": "4fd22035e81d98fffced65a8c20d9ec4daa9671c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/4fd22035e81d98fffced65a8c20d9ec4daa9671c",
"reference": "4fd22035e81d98fffced65a8c20d9ec4daa9671c",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"php": "^7.3|^8.0",
"psr/log": "^1.0 | ^2.0 | ^3.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0"
},
"require-dev": {
"pestphp/pest": "^1.21",
"phpunit/phpunit": "^8.5.21|^9.4.4",
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\ImageOptimizer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily optimize images using PHP",
"homepage": "https://github.com/spatie/image-optimizer",
"keywords": [
"image-optimizer",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/image-optimizer/issues",
"source": "https://github.com/spatie/image-optimizer/tree/1.8.0"
},
"time": "2024-11-04T08:24:54+00:00"
},
{
"name": "spatie/invade",
"version": "2.1.0",
@@ -5736,6 +6319,116 @@
],
"time": "2024-05-17T09:06:10+00:00"
},
{
"name": "spatie/laravel-medialibrary",
"version": "11.15.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-medialibrary.git",
"reference": "9d1e9731d36817d1649bc584b2c40c0c9d4bcfac"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/9d1e9731d36817d1649bc584b2c40c0c9d4bcfac",
"reference": "9d1e9731d36817d1649bc584b2c40c0c9d4bcfac",
"shasum": ""
},
"require": {
"composer/semver": "^3.4",
"ext-exif": "*",
"ext-fileinfo": "*",
"ext-json": "*",
"illuminate/bus": "^10.2|^11.0|^12.0",
"illuminate/conditionable": "^10.2|^11.0|^12.0",
"illuminate/console": "^10.2|^11.0|^12.0",
"illuminate/database": "^10.2|^11.0|^12.0",
"illuminate/pipeline": "^10.2|^11.0|^12.0",
"illuminate/support": "^10.2|^11.0|^12.0",
"maennchen/zipstream-php": "^3.1",
"php": "^8.2",
"spatie/image": "^3.3.2",
"spatie/laravel-package-tools": "^1.16.1",
"spatie/temporary-directory": "^2.2",
"symfony/console": "^6.4.1|^7.0"
},
"conflict": {
"php-ffmpeg/php-ffmpeg": "<0.6.1"
},
"require-dev": {
"aws/aws-sdk-php": "^3.293.10",
"ext-imagick": "*",
"ext-pdo_sqlite": "*",
"ext-zip": "*",
"guzzlehttp/guzzle": "^7.8.1",
"larastan/larastan": "^2.7|^3.0",
"league/flysystem-aws-s3-v3": "^3.22",
"mockery/mockery": "^1.6.7",
"orchestra/testbench": "^7.0|^8.17|^9.0|^10.0",
"pestphp/pest": "^2.28|^3.5",
"phpstan/extension-installer": "^1.3.1",
"spatie/laravel-ray": "^1.33",
"spatie/pdf-to-image": "^2.2|^3.0",
"spatie/pest-expectations": "^1.13",
"spatie/pest-plugin-snapshots": "^2.1"
},
"suggest": {
"league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage",
"php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails",
"spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\MediaLibrary\\MediaLibraryServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\MediaLibrary\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Associate files with Eloquent models",
"homepage": "https://github.com/spatie/laravel-medialibrary",
"keywords": [
"cms",
"conversion",
"downloads",
"images",
"laravel",
"laravel-medialibrary",
"media",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-medialibrary/issues",
"source": "https://github.com/spatie/laravel-medialibrary/tree/11.15.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-09-19T06:51:45+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.92.7",
@@ -5797,6 +6490,89 @@
],
"time": "2025-07-17T15:46:43+00:00"
},
{
"name": "spatie/laravel-permission",
"version": "6.21.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-permission.git",
"reference": "6a118e8855dfffcd90403aab77bbf35a03db51b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/6a118e8855dfffcd90403aab77bbf35a03db51b3",
"reference": "6a118e8855dfffcd90403aab77bbf35a03db51b3",
"shasum": ""
},
"require": {
"illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0",
"illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0",
"illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0",
"illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0",
"php": "^8.0"
},
"require-dev": {
"laravel/passport": "^11.0|^12.0",
"laravel/pint": "^1.0",
"orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0",
"phpunit/phpunit": "^9.4|^10.1|^11.5"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Permission\\PermissionServiceProvider"
]
},
"branch-alias": {
"dev-main": "6.x-dev",
"dev-master": "6.x-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Spatie\\Permission\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Permission handling for Laravel 8.0 and up",
"homepage": "https://github.com/spatie/laravel-permission",
"keywords": [
"acl",
"laravel",
"permission",
"permissions",
"rbac",
"roles",
"security",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-permission/issues",
"source": "https://github.com/spatie/laravel-permission/tree/6.21.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-07-23T16:08:05+00:00"
},
{
"name": "spatie/laravel-tags",
"version": "4.10.0",
@@ -6015,6 +6791,67 @@
],
"time": "2025-02-21T14:16:57+00:00"
},
{
"name": "spatie/temporary-directory",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/temporary-directory.git",
"reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/temporary-directory/zipball/580eddfe9a0a41a902cac6eeb8f066b42e65a32b",
"reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\TemporaryDirectory\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Vanderbist",
"email": "alex@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily create, use and destroy temporary directories",
"homepage": "https://github.com/spatie/temporary-directory",
"keywords": [
"php",
"spatie",
"temporary-directory"
],
"support": {
"issues": "https://github.com/spatie/temporary-directory/issues",
"source": "https://github.com/spatie/temporary-directory/tree/2.3.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-01-13T13:04:43+00:00"
},
{
"name": "stephenjude/filament-blog",
"version": "4.2.1",

View File

@@ -13,7 +13,7 @@ return [
|
*/
'name' => env('APP_NAME', 'Laravel'),
'name' => env('APP_NAME', 'Fotospiel.App'),
/*
|--------------------------------------------------------------------------
@@ -65,7 +65,7 @@ return [
|
*/
'timezone' => 'UTC',
'timezone' => 'Europe/Berlin',
/*
|--------------------------------------------------------------------------
@@ -123,4 +123,6 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
'require_registration' => env('REQUIRE_REGISTRATION', true),
];

View File

@@ -67,8 +67,8 @@ return [
'h1',
'h2',
'h3',
'hr',
'image',
//'hr',
//'image',
'italic',
'link',
'orderedList',

120
config/filament.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Broadcasting
|--------------------------------------------------------------------------
|
| By uncommenting the Laravel Echo configuration, you may connect Filament
| to any Pusher-compatible websockets server.
|
| This will allow your users to receive real-time notifications.
|
*/
'broadcasting' => [
// 'echo' => [
// 'broadcaster' => 'pusher',
// 'key' => env('VITE_PUSHER_APP_KEY'),
// 'cluster' => env('VITE_PUSHER_APP_CLUSTER'),
// 'wsHost' => env('VITE_PUSHER_HOST'),
// 'wsPort' => env('VITE_PUSHER_PORT'),
// 'wssPort' => env('VITE_PUSHER_PORT'),
// 'authEndpoint' => '/broadcasting/auth',
// 'disableStats' => true,
// 'encrypted' => true,
// 'forceTLS' => true,
// ],
],
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| This is the storage disk Filament will use to store files. You may use
| any of the disks defined in the `config/filesystems.php`.
|
*/
'default_filesystem_disk' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Assets Path
|--------------------------------------------------------------------------
|
| This is the directory where Filament's assets will be published to. It
| is relative to the `public` directory of your Laravel application.
|
| After changing the path, you should run `php artisan filament:assets`.
|
*/
'assets_path' => null,
/*
|--------------------------------------------------------------------------
| Cache Path
|--------------------------------------------------------------------------
|
| This is the directory that Filament will use to store cache files that
| are used to optimize the registration of components.
|
| After changing the path, you should run `php artisan filament:cache-components`.
|
*/
'cache_path' => base_path('bootstrap/cache/filament'),
/*
|--------------------------------------------------------------------------
| Livewire Loading Delay
|--------------------------------------------------------------------------
|
| This sets the delay before loading indicators appear.
|
| Setting this to 'none' makes indicators appear immediately, which can be
| desirable for high-latency connections. Setting it to 'default' applies
| Livewire's standard 200ms delay.
|
*/
'livewire_loading_delay' => 'default',
/*
|--------------------------------------------------------------------------
| File Generation
|--------------------------------------------------------------------------
|
| Artisan commands that generate files can be configured here by setting
| configuration flags that will impact their location or content.
|
| Often, this is useful to preserve file generation behavior from a
| previous version of Filament, to ensure consistency between older and
| newer generated files. These flags are often documented in the upgrade
| guide for the version of Filament you are upgrading to.
|
*/
'file_generation' => [
'flags' => [],
],
/*
|--------------------------------------------------------------------------
| System Route Prefix
|--------------------------------------------------------------------------
|
| This is the prefix used for the system routes that Filament registers,
| such as the routes for downloading exports and failed import rows.
|
*/
'system_route_prefix' => 'filament',
];

View File

@@ -10,8 +10,8 @@ return new class extends Migration {
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->json('name');
$table->json('description')->nullable();
$table->dateTime('date');
$table->string('slug')->unique();
$table->string('location')->nullable();

View File

@@ -12,9 +12,9 @@ return new class extends Migration {
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->unsignedBigInteger('emotion_id')->nullable();
$table->unsignedBigInteger('event_type_id')->nullable();
$table->string('title');
$table->text('description')->nullable();
$table->text('example_text')->nullable();
$table->json('title');
$table->json('description')->nullable();
$table->json('example_text')->nullable();
$table->dateTime('due_date')->nullable();
$table->boolean('is_completed')->default(false);
$table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium');

View File

@@ -72,7 +72,7 @@ return new class extends Migration
}
// Migrate tenant credits to tenant_packages (Free package)
DB::table('tenants')->where('event_credits_balance', '>', 0)->chunk(100, function ($tenants) {
DB::table('tenants')->where('event_credits_balance', '>', 0)->orderBy('id')->chunk(100, function ($tenants) {
foreach ($tenants as $tenant) {
$freePackageId = DB::table('packages')->where('name', 'Free/Test')->first()->id;
DB::table('tenant_packages')->insert([
@@ -106,7 +106,7 @@ return new class extends Migration
});
// Migrate event purchases to event_packages (if any existing events)
DB::table('events')->chunk(100, function ($events) {
DB::table('events')->orderBy('id')->chunk(100, function ($events) {
foreach ($events as $event) {
if ($event->tenant->event_credits_balance > 0) { // or check if event was created with credits
$freePackageId = DB::table('packages')->where('name', 'Free/Test')->first()->id;

View File

@@ -15,15 +15,37 @@ return new class extends Migration
Schema::dropIfExists('purchase_history');
Schema::dropIfExists('event_purchases');
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn([
'event_credits_balance',
'subscription_tier',
'subscription_expires_at',
'free_event_granted_at',
'total_revenue'
]);
});
if (Schema::hasTable('package_purchases')) {
Schema::table('package_purchases', function (Blueprint $table) {
$table->dropIndex(['tenant_id', 'purchased_at']);
});
}
if (Schema::hasColumn('tenants', 'event_credits_balance')) {
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('event_credits_balance');
});
}
if (Schema::hasColumn('tenants', 'subscription_tier')) {
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('subscription_tier');
});
}
if (Schema::hasColumn('tenants', 'subscription_expires_at')) {
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('subscription_expires_at');
});
}
if (Schema::hasColumn('tenants', 'free_event_granted_at')) {
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('free_event_granted_at');
});
}
if (Schema::hasColumn('tenants', 'total_revenue')) {
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('total_revenue');
});
}
}
/**

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('first_name')->nullable()->after('name');
$table->string('last_name')->nullable()->after('first_name');
$table->text('address')->nullable()->after('last_name');
$table->string('phone')->nullable()->after('address');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['first_name', 'last_name', 'address', 'phone']);
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('cascade')->after('id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('package_purchases', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->foreignId('tenant_id')->constrained()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('package_purchases', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->foreignId('tenant_id')->nullable()->constrained()->change();
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unique('username');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['username']);
});
}
};

View File

@@ -1,4 +1,4 @@
<?php
<?php
namespace Database\Seeders;
@@ -14,6 +14,7 @@ class DatabaseSeeder extends Seeder
// Seed basic system data
$this->call([
LegalPagesSeeder::class,
PackageSeeder::class,
]);
// Seed core demo data for frontend previews

View File

@@ -1,4 +1,4 @@
<?php
<?php
namespace Database\Seeders;

View File

@@ -11,7 +11,10 @@ class DemoEventSeeder extends Seeder
{
$type = EventType::where('slug','wedding')->first();
if(!$type){ return; }
$demoTenant = \App\Models\Tenant::where('slug', 'demo')->first();
if (!$demoTenant) { return; }
Event::updateOrCreate(['slug'=>'demo-wedding-2025'], [
'tenant_id' => $demoTenant->id,
'name' => ['de'=>'Demo Hochzeit 2025','en'=>'Demo Wedding 2025'],
'description' => ['de'=>'Demo-Event','en'=>'Demo event'],
'date' => now()->addMonths(3)->toDateString(),

View File

@@ -4,6 +4,7 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Package;
use App\Enums\PackageType;
class PackageSeeder extends Seeder
{
@@ -15,124 +16,98 @@ class PackageSeeder extends Seeder
// Endcustomer Packages
Package::create([
'name' => 'Free / Test',
'type' => 'endcustomer',
'type' => PackageType::ENDCUSTOMER,
'price' => 0.00,
'max_photos' => 30,
'max_guests' => 10,
'gallery_days' => 3,
'max_guests' => 50,
'gallery_days' => 7,
'max_tasks' => 5,
'watermark_allowed' => false,
'watermark_allowed' => true,
'branding_allowed' => false,
'features' => json_encode([
'basic_uploads' => true,
'limited_sharing' => true,
'no_branding' => true,
]),
'description' => 'Ideal für kleine Test-Events oder erste Erfahrungen.',
]);
Package::create([
'name' => 'Starter',
'type' => 'endcustomer',
'price' => 19.00,
'max_photos' => 300,
'max_guests' => 50,
'gallery_days' => 14,
'max_tasks' => 20,
'type' => PackageType::ENDCUSTOMER,
'price' => 29.00,
'max_photos' => 200,
'max_guests' => 100,
'gallery_days' => 30,
'max_tasks' => 10,
'watermark_allowed' => true,
'branding_allowed' => false,
'features' => json_encode([
'extended_gallery' => true,
'guest_sharing' => true,
'basic_analytics' => true,
'basic_uploads' => true,
'unlimited_sharing' => true,
'no_watermark' => true,
'custom_tasks' => true,
]),
'description' => 'Perfekt für kleine Events wie Geburtstage oder Firmenfeiern.',
]);
Package::create([
'name' => 'Pro',
'type' => 'endcustomer',
'price' => 49.00,
'type' => PackageType::ENDCUSTOMER,
'price' => 79.00,
'max_photos' => 1000,
'max_guests' => 200,
'gallery_days' => 30,
'max_tasks' => 50,
'max_guests' => 500,
'gallery_days' => 90,
'max_tasks' => 20,
'watermark_allowed' => false,
'branding_allowed' => false,
'features' => json_encode([
'basic_uploads' => true,
'unlimited_sharing' => true,
'no_watermark' => true,
'custom_tasks' => true,
'advanced_analytics' => true,
'priority_support' => true,
]),
]);
// Reseller Packages
Package::create([
'name' => 'S (Small Reseller)',
'type' => PackageType::RESELLER,
'price' => 199.00,
'max_photos' => 500, // per event limit
'max_guests' => null, // unlimited
'gallery_days' => null,
'max_tasks' => null, // unlimited
'watermark_allowed' => true,
'branding_allowed' => true,
'max_events_per_year' => 5,
'expires_after' => now()->addYear(),
'features' => json_encode([
'unlimited_sharing' => true,
'advanced_analytics' => true,
'reseller_dashboard' => true,
'custom_branding' => true,
'priority_support' => true,
]),
'description' => 'Für große Events wie Hochzeiten oder Konferenzen.',
]);
// Reseller Packages (jährliche Subscriptions)
Package::create([
'name' => 'Reseller S',
'type' => 'reseller',
'price' => 149.00,
'max_events_per_year' => 5,
'max_photos' => null, // Kein globales Limit, pro Event
'max_guests' => null,
'name' => 'M (Medium Reseller)',
'type' => PackageType::RESELLER,
'price' => 399.00,
'max_photos' => 1000, // per event limit
'max_guests' => null, // unlimited
'gallery_days' => null,
'max_tasks' => null,
'max_tasks' => null, // unlimited
'watermark_allowed' => true,
'branding_allowed' => true,
'expires_after' => now()->addYear(), // Jährlich
'features' => json_encode([
'event_management' => true,
'reseller_dashboard' => true,
'bulk_event_creation' => true,
'5_events_included' => true,
]),
'description' => 'Einstieg für kleine Agenturen: 5 Events pro Jahr.',
]);
Package::create([
'name' => 'Reseller M',
'type' => 'reseller',
'price' => 299.00,
'max_events_per_year' => 15,
'max_photos' => null,
'max_guests' => null,
'gallery_days' => null,
'max_tasks' => null,
'watermark_allowed' => true,
'branding_allowed' => true,
'expires_after' => now()->addYear(),
'features' => json_encode([
'event_management' => true,
'reseller_dashboard' => true,
'bulk_event_creation' => true,
'custom_branding' => true,
'priority_support' => true,
'advanced_reporting' => true,
'15_events_included' => true,
]),
'description' => 'Für wachsende Agenturen: 15 Events pro Jahr.',
]);
Package::create([
'name' => 'Reseller L',
'type' => 'reseller',
'price' => 499.00,
'max_events_per_year' => 30,
'max_photos' => null,
'max_guests' => null,
'gallery_days' => null,
'max_tasks' => null,
'watermark_allowed' => true,
'branding_allowed' => true,
'expires_after' => now()->addYear(),
'features' => json_encode([
'event_management' => true,
'reseller_dashboard' => true,
'bulk_event_creation' => true,
'priority_support' => true,
'custom_integration' => true,
'30_events_included' => true,
]),
'description' => 'Für große Agenturen: 30 Events pro Jahr mit Premium-Features.',
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
return [
'failed' => 'Diese Anmeldedaten wurden nicht gefunden.',
'password' => 'Das Passwort ist falsch.',
'throttle' => 'Zu viele Login-Versuche. Bitte versuche es in :seconds Sekunden erneut.',
'login' => [
'title' => 'Anmelden',
'username_or_email' => 'Username oder E-Mail',
'password' => 'Passwort',
'remember' => 'Angemeldet bleiben',
'submit' => 'Anmelden',
],
'register' => [
'title' => 'Registrieren',
'name' => 'Vollständiger Name',
'username' => 'Username',
'email' => 'E-Mail-Adresse',
'password' => 'Passwort',
'password_confirmation' => 'Passwort bestätigen',
'first_name' => 'Vorname',
'last_name' => 'Nachname',
'address' => 'Adresse',
'phone' => 'Telefonnummer',
'privacy_consent' => 'Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.',
'submit' => 'Registrieren',
],
'verification' => [
'notice' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse.',
'resend' => 'E-Mail erneut senden',
],
];

View File

@@ -0,0 +1,17 @@
<?php
return [
'title' => 'Profil bearbeiten',
'first_name' => 'Vorname',
'first_name_placeholder' => 'Ihr Vorname',
'last_name' => 'Nachname',
'last_name_placeholder' => 'Ihr Nachname',
'address' => 'Adresse',
'phone' => 'Telefonnummer',
'phone_placeholder' => 'Ihre Telefonnummer',
'password' => 'Passwort',
'password_placeholder' => 'Neues Passwort (optional)',
'save' => 'Speichern',
'delete_account' => 'Account löschen',
'delete' => 'Löschen',
];

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Purchase Confirmation</title>
</head>
<body>
<h1>Kauf-Bestätigung</h1>
<p>Vielen Dank für Ihren Kauf, {{ $purchase->user->fullName }}!</p>
<p>Package: {{ $purchase->package->name }}</p>
<p>Preis: {{ $purchase->amount }} </p>
<p>Das Package ist nun in Ihrem Tenant-Account aktiviert.</p>
<p>Mit freundlichen Grüßen,<br>Das Fotospiel-Team</p>
</body>
</html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Welcome to Fotospiel</title>
</head>
<body>
<h1>Willkommen bei Fotospiel, {{ $user->fullName }}!</h1>
<p>Vielen Dank für Ihre Registrierung. Ihr Account ist nun aktiv.</p>
<p>Username: {{ $user->username }}</p>
<p>E-Mail: {{ $user->email }}</p>
<p>Bitte verifizieren Sie Ihre E-Mail-Adresse, um auf das Admin-Panel zuzugreifen.</p>
<p>Mit freundlichen Grüßen,<br>Das Fotospiel-Team</p>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes')</title>
<meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.">
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css'])
<style>
@keyframes aurora {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.bg-aurora {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: aurora 15s ease infinite;
}
</style>
</head>
<body class="bg-gray-50 text-gray-900">
<!-- Header -->
<header class="bg-white shadow-md sticky top-0 z-50">
<div class="container mx-auto px-4 py-4 flex items-center justify-between">
<div class="flex items-center space-x-2">
<a href="/" class="text-2xl font-bold text-gray-900">Fotospiel</a>
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
<nav class="hidden md:flex space-x-6 items-center">
<a href="#how-it-works" class="text-gray-600 hover:text-gray-900">How it works</a>
<a href="#features" class="text-gray-600 hover:text-gray-900">Features</a>
<div class="relative group">
<button class="text-gray-600 hover:text-gray-900">Occasions</button>
<div class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg hidden group-hover:block">
<a href="/occasions/weddings" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Weddings</a>
<a href="/occasions/birthdays" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Birthdays</a>
<a href="/occasions/corporate-events" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Corporate Events</a>
<a href="/occasions/family-celebrations" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Family Celebrations</a>
</div>
</div>
<a href="/blog" class="text-gray-600 hover:text-gray-900">Blog</a>
<a href="/packages" class="text-gray-600 hover:text-gray-900">Packages</a>
<a href="#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
<a href="/packages" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">Packages entdecken</a>
</nav>
<!-- Mobile Menu Placeholder (Hamburger) -->
<button class="md:hidden text-gray-600"></button>
</div>
</header>
<main>
@yield('content')
</main>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-8 px-4">
<div class="container mx-auto text-center">
<p>&copy; 2025 Fotospiel GmbH. Alle Rechte vorbehalten.</p>
<div class="mt-4 space-x-4">
<a href="/impressum" class="hover:text-[#FFB6C1]">Impressum</a>
<a href="/datenschutz" class="hover:text-[#FFB6C1]">Datenschutz</a>
<a href="#contact" class="hover:text-[#FFB6C1]">Kontakt</a>
</div>
</div>
</footer>
@stack('scripts')
</body>
</html>

View File

@@ -15,5 +15,14 @@
<p>Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.</p>
<p>Ihre Rechte: Auskunft, Löschung, Widerspruch. Kontaktieren Sie uns unter <a href="/kontakt">Kontakt</a>.</p>
<p>Cookies: Nur funktionale Cookies für die PWA.</p>
<h2>Persönliche Datenverarbeitung</h2>
<p>Bei der Registrierung und Nutzung des Systems werden folgende persönliche Daten verarbeitet: Vor- und Nachname, Adresse, Telefonnummer, E-Mail-Adresse, Username. Diese Daten werden zur Erfüllung des Vertrags (Package-Kauf, Tenant-Management) und für die Authentifizierung verwendet. Die Verarbeitung erfolgt gemäß Art. 6 Abs. 1 lit. b DSGVO.</p>
<h2>Account-Löschung</h2>
<p>Sie haben das Recht, Ihre persönlichen Daten jederzeit löschen zu lassen (Recht auf Löschung, Art. 17 DSGVO). Kontaktieren Sie uns unter [E-Mail] zur Löschung Ihres Accounts. Alle zugehörigen Daten (Events, Photos, Purchases) werden gelöscht, soweit keine gesetzlichen Aufbewahrungspflichten bestehen.</p>
<h2>Datensicherheit</h2>
<p>Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).</p>
</body>
</html>

View File

@@ -0,0 +1,40 @@
@extends('layouts.marketing')
@section('title', 'Kontakt - Fotospiel')
@section('content')
<div class="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold text-center mb-8">Kontakt</h1>
<p class="text-center text-gray-600 mb-8">Haben Sie Fragen? Schreiben Sie uns!</p>
<form method="POST" action="{{ route('kontakt.submit') }}" class="space-y-4">
@csrf
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">Name</label>
<input type="text" id="name" name="name" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">E-Mail</label>
<input type="email" id="email" name="email" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]">
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 mb-2">Nachricht</label>
<textarea id="message" name="message" rows="4" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"></textarea>
</div>
<button type="submit" class="w-full bg-[#FFB6C1] text-white py-3 rounded-md font-semibold hover:bg-[#FF69B4] transition">Senden</button>
</form>
@if (session('success'))
<p class="mt-4 text-green-600 text-center">{{ session('success') }}</p>
@endif
@if ($errors->any())
<div class="mt-4 p-4 bg-red-100 border border-red-400 rounded-md">
<ul class="list-disc list-inside">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
</div>
</div>
@endsection

View File

@@ -1,57 +1,8 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fotospiel - Event-Fotos einfach und sicher mit QR-Codes</title>
<meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.">
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css'])
<style>
@keyframes aurora {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.bg-aurora {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: aurora 15s ease infinite;
}
</style>
</head>
<body class="bg-gray-50 text-gray-900">
<!-- Header -->
<header class="bg-white shadow-md sticky top-0 z-50">
<div class="container mx-auto px-4 py-4 flex items-center justify-between">
<div class="flex items-center space-x-2">
<a href="/" class="text-2xl font-bold text-gray-900">Fotospiel</a>
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
<nav class="hidden md:flex space-x-6 items-center">
<a href="#how-it-works" class="text-gray-600 hover:text-gray-900">How it works</a>
<a href="#features" class="text-gray-600 hover:text-gray-900">Features</a>
<div class="relative group">
<button class="text-gray-600 hover:text-gray-900">Occasions</button>
<div class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg hidden group-hover:block">
<a href="/occasions/weddings" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Weddings</a>
<a href="/occasions/birthdays" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Birthdays</a>
<a href="/occasions/corporate-events" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Corporate Events</a>
<a href="/occasions/family-celebrations" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Family Celebrations</a>
</div>
</div>
<a href="/blog" class="text-gray-600 hover:text-gray-900">Blog</a>
<a href="/packages" class="text-gray-600 hover:text-gray-900">Packages</a>
<a href="#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
<a href="/packages" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">Packages entdecken</a>
</nav>
<!-- Mobile Menu Placeholder (Hamburger) -->
<button class="md:hidden text-gray-600"></button>
</div>
</header>
@extends('layouts.marketing')
@section('title', 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes')
@section('content')
<!-- Hero Section id="hero" -->
<section id="hero" class="bg-aurora text-white py-20 px-4">
<div class="container mx-auto flex flex-col md:flex-row items-center gap-8 max-w-6xl">
@@ -142,7 +93,7 @@
<section id="contact" class="py-20 px-4 bg-white">
<div class="container mx-auto max-w-2xl">
<h2 class="text-3xl font-bold text-center mb-12">Kontakt</h2>
<form method="POST" action="/kontakt" class="space-y-4">
<form method="POST" action="{{ route('kontakt.submit') }}" class="space-y-4">
@csrf
<div>
<label for="name" class="block text-sm font-medium mb-2">Name</label>
@@ -210,17 +161,4 @@
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-8 px-4">
<div class="container mx-auto text-center">
<p>&copy; 2025 Fotospiel GmbH. Alle Rechte vorbehalten.</p>
<div class="mt-4 space-x-4">
<a href="/impressum" class="hover:text-[#FFB6C1]">Impressum</a>
<a href="/datenschutz" class="hover:text-[#FFB6C1]">Datenschutz</a>
<a href="#contact" class="hover:text-[#FFB6C1]">Kontakt</a>
</div>
</div>
</footer>
</body>
</html>
@endsection

View File

@@ -3,12 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $post->meta_title ?? $post->title }} - Fotospiel</title>
<meta name="description" content="{{ $post->meta_description ?? $post->excerpt }}">
<meta property="og:title" content="{{ $post->meta_title ?? $post->title }}">
<meta property="og:description" content="{{ $post->meta_description ?? $post->excerpt }}">
<meta property="og:image" content="{{ $post->featured_image }}">
<meta property="og:url" content="{{ route('blog.show', $post) }}">
<title>{{ $post->title }} - Fotospiel Blog</title>
<meta name="description" content="{{ Str::limit(strip_tags($post->content), 160) }}">
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css'])
</head>
@@ -33,44 +29,35 @@
</div>
</div>
<a href="/blog" class="text-gray-900 font-semibold">Blog</a>
<a href="/marketing#pricing" class="text-gray-600 hover:text-gray-900">Pricing</a>
<a href="/packages" class="text-gray-600 hover:text-gray-900">Pricing</a>
<a href="/marketing#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
</nav>
<a href="/buy-credits/basic" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Jetzt starten</a>
<a href="/packages" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Jetzt starten</a>
</div>
</header>
<!-- Blog Post Hero -->
<section class="py-20 px-4 bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] text-white">
<div class="container mx-auto max-w-4xl">
@if ($post->featured_image)
<img src="{{ $post->featured_image }}" alt="{{ $post->title }}" class="w-full h-64 object-cover rounded mb-8">
@endif
<!-- Hero for Single Post -->
<section class="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] text-white py-20 px-4">
<div class="container mx-auto text-center">
<h1 class="text-4xl md:text-5xl font-bold mb-4">{{ $post->title }}</h1>
<p class="text-xl mb-8">{{ $post->excerpt }}</p>
<p class="text-sm text-gray-200">Veröffentlicht am {{ $post->published_at->format('d.m.Y') }}</p>
<p class="text-lg mb-8">Von {{ $post->author->name ?? 'Fotospiel Team' }} | {{ $post->published_at->format('d.m.Y') }}</p>
@if ($post->featured_image)
<img src="{{ $post->featured_image }}" alt="{{ $post->title }}" class="mx-auto rounded-lg shadow-lg max-w-2xl">
@endif
</div>
</section>
<!-- Blog Post Content -->
<section class="py-20 px-4">
<div class="container mx-auto max-w-4xl">
<div class="prose max-w-none">
{!! $post->content !!}
</div>
<div class="mt-8 p-4 bg-gray-100 rounded">
<h3 class="font-semibold mb-2">Kategorien:</h3>
@foreach ($post->categories as $category)
<span class="inline-block bg-[#FFB6C1] text-white px-2 py-1 rounded text-sm mr-2 mb-2">{{ $category->name }}</span>
@endforeach
<h3 class="font-semibold mt-4 mb-2">Tags:</h3>
@foreach ($post->tags as $tag)
<span class="inline-block bg-gray-200 text-gray-800 px-2 py-1 rounded text-sm mr-2 mb-2">#{{ $tag->name }}</span>
@endforeach
</div>
<div class="mt-8 text-center">
<a href="/blog" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Zurück zum Blog</a>
</div>
<!-- Post Content -->
<section class="py-20 px-4 bg-white">
<div class="container mx-auto max-w-4xl prose prose-lg max-w-none">
{!! $post->content !!}
</div>
</section>
<!-- Back to Blog -->
<section class="py-10 px-4 bg-gray-50">
<div class="container mx-auto text-center">
<a href="/blog" class="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition">Zurück zum Blog</a>
</div>
</section>

View File

@@ -1,41 +1,8 @@
<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fotospiel - Blog</title>
<meta name="description" content="Tipps, News und Anleitungen zu Event-Fotos mit QR-Codes und PWA.">
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css'])
</head>
<body class="bg-gray-50 text-gray-900">
<!-- Shared Header (wie in occasions.blade.php) -->
<header class="bg-white shadow-md sticky top-0 z-50">
<div class="container mx-auto px-4 py-4 flex items-center justify-between">
<div class="flex items-center space-x-2">
<a href="/marketing" class="text-2xl font-bold text-gray-900">Fotospiel</a>
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
</div>
<nav class="hidden md:flex space-x-6">
<a href="/marketing#how-it-works" class="text-gray-600 hover:text-gray-900">How it works</a>
<a href="/marketing#features" class="text-gray-600 hover:text-gray-900">Features</a>
<div class="relative">
<button class="text-gray-600 hover:text-gray-900">Occasions</button>
<div class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg">
<a href="/occasions/weddings" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Weddings</a>
<a href="/occasions/birthdays" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Birthdays</a>
<a href="/occasions/corporate-events" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Corporate Events</a>
<a href="/occasions/family-celebrations" class="block px-4 py-2 text-gray-600 hover:text-gray-900">Family Celebrations</a>
</div>
</div>
<a href="/blog" class="text-gray-900 font-semibold">Blog</a>
<a href="/marketing#pricing" class="text-gray-600 hover:text-gray-900">Pricing</a>
<a href="/marketing#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
</nav>
<a href="/buy-credits/basic" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Jetzt starten</a>
</div>
</header>
@extends('layouts.marketing')
@section('title', 'Fotospiel - Blog')
@section('content')
<!-- Hero for Blog -->
<section class="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] text-white py-20 px-4">
<div class="container mx-auto text-center">
@@ -73,17 +40,4 @@
@endif
</div>
</section>
<!-- Footer (wie in occasions.blade.php) -->
<footer class="bg-gray-800 text-white py-8 px-4 mt-20">
<div class="container mx-auto text-center">
<p>&copy; 2025 Fotospiel GmbH. Alle Rechte vorbehalten.</p>
<div class="mt-4 space-x-4">
<a href="/impressum" class="hover:text-[#FFB6C1]">Impressum</a>
<a href="/datenschutz" class="hover:text-[#FFB6C1]">Datenschutz</a>
<a href="/marketing#contact" class="hover:text-[#FFB6C1]">Kontakt</a>
</div>
</div>
</footer>
</body>
</html>
@endsection

View File

@@ -29,7 +29,7 @@
</div>
</div>
<a href="/blog" class="text-gray-600 hover:text-gray-900">Blog</a>
<a href="/marketing#pricing" class="text-gray-600 hover:text-gray-900">Pricing</a>
<a href="/packages" class="text-gray-600 hover:text-gray-900">Pricing</a>
<a href="/marketing#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
</nav>
<a href="/packages" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Packages wählen</a>

View File

@@ -73,7 +73,11 @@
</li>
@endif
@if($package->features)
@foreach(json_decode($package->features, true) as $feature => $enabled)
@php
$features = is_array($package->features) ? $package->features : (is_string($package->features) ? json_decode($package->features, true) : []);
$features = is_array($features) ? $features : [];
@endphp
@foreach($features as $feature => $enabled)
@if($enabled)
<li class="flex items-center text-sm text-gray-700">
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
@@ -85,8 +89,8 @@
@endforeach
@endif
</ul>
<a href="/packages?type=endcustomer&package_id={{ $package->id }}" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md text-center font-semibold transition duration-300">
{{ __('marketing.packages.buy_now') }}
<a href="{{ route('register', ['package_id' => $package->id]) }}" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md text-center font-semibold transition duration-300">
{{ __('marketing.packages.register_buy') }}
</a>
</div>
@endforeach
@@ -118,7 +122,11 @@
</li>
@endif
@if($package->features)
@foreach(json_decode($package->features, true) as $feature => $enabled)
@php
$features = is_array($package->features) ? $package->features : (is_string($package->features) ? json_decode($package->features, true) : []);
$features = is_array($features) ? $features : [];
@endphp
@foreach($features as $feature => $enabled)
@if($enabled)
<li class="flex items-center text-sm text-gray-700">
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
@@ -130,8 +138,8 @@
@endforeach
@endif
</ul>
<a href="/packages?type=reseller&package_id={{ $package->id }}" class="w-full bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded-md text-center font-semibold transition duration-300">
{{ __('marketing.packages.subscribe_now') }}
<a href="{{ route('register', ['package_id' => $package->id]) }}" class="w-full bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded-md text-center font-semibold transition duration-300">
{{ __('marketing.packages.register_subscribe') }}
</a>
</div>
@endforeach

View File

@@ -0,0 +1,99 @@
@extends('layouts.marketing')
@section('title', __('profile.title'))
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
{{ __('profile.title') }}
</h2>
</div>
<form class="mt-8 space-y-6" action="{{ route('profile.update') }}" method="POST">
@csrf
@method('PATCH')
<!-- First Name -->
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">
{{ __('profile.first_name') }}
</label>
<input id="first_name" name="first_name" type="text" required
value="{{ old('first_name', $user->first_name) }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('first_name') border-red-500 @enderror"
placeholder="{{ __('profile.first_name_placeholder') }}">
@error('first_name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Last Name -->
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">
{{ __('profile.last_name') }}
</label>
<input id="last_name" name="last_name" type="text" required
value="{{ old('last_name', $user->last_name) }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('last_name') border-red-500 @enderror"
placeholder="{{ __('profile.last_name_placeholder') }}">
@error('last_name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Address -->
<div>
<label for="address" class="block text-sm font-medium text-gray-700">
{{ __('profile.address') }}
</label>
<textarea id="address" name="address" required rows="3"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('address') border-red-500 @enderror">{{ old('address', $user->address) }}</textarea>
@error('address')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Phone -->
<div>
<label for="phone" class="block text-sm font-medium text-gray-700">
{{ __('profile.phone') }}
</label>
<input id="phone" name="phone" type="tel" required
value="{{ old('phone', $user->phone) }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('phone') border-red-500 @enderror"
placeholder="{{ __('profile.phone_placeholder') }}">
@error('phone')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700">
{{ __('profile.password') }}
</label>
<input id="password" name="password" type="password"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('password') border-red-500 @enderror"
placeholder="{{ __('profile.password_placeholder') }}">
@error('password')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<button type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-300">
{{ __('profile.save') }}
</button>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">
{{ __('profile.delete_account') }} <a href="#" class="text-red-600 hover:text-red-500">{{ __('profile.delete') }}</a>
</p>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,182 @@
@extends('layouts.marketing')
@section('title', __('auth.register'))
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
{{ __('auth.register') }}
</h2>
@if($package ?? false)
<div class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
<h3 class="text-lg font-semibold text-blue-900 mb-2">{{ $package->name }}</h3>
<p class="text-blue-800 mb-2">{{ $package->description }}</p>
<p class="text-sm text-blue-700">
{{ $package->price == 0 ? __('marketing.free') : $package->price . ' €' }}
</p>
</div>
@endif
</div>
<form class="mt-8 space-y-6" action="{{ route('register.store') }}" method="POST">
@csrf
@if($package ?? false)
<input type="hidden" name="package_id" value="{{ $package->id }}">
@endif
<!-- Name Field -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700">
{{ __('auth.name') }}
</label>
<input id="name" name="name" type="text" required
value="{{ old('name') }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('name') border-red-500 @enderror"
placeholder="{{ __('auth.name_placeholder') }}">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Username Field -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700">
{{ __('auth.username') }}
</label>
<input id="username" name="username" type="text" required
value="{{ old('username') }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('username') border-red-500 @enderror"
placeholder="{{ __('auth.username_placeholder') }}">
@error('username')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Email Field -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
{{ __('auth.email') }}
</label>
<input id="email" name="email" type="email" required
value="{{ old('email') }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('email') border-red-500 @enderror"
placeholder="{{ __('auth.email_placeholder') }}">
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Password Field -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700">
{{ __('auth.password') }}
</label>
<input id="password" name="password" type="password" required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('password') border-red-500 @enderror"
placeholder="{{ __('auth.password_placeholder') }}">
@error('password')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Confirm Password Field -->
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">
{{ __('auth.confirm_password') }}
</label>
<input id="password_confirmation" name="password_confirmation" type="password" required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="{{ __('auth.confirm_password_placeholder') }}">
</div>
<!-- First Name Field -->
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">
{{ __('profile.first_name') }}
</label>
<input id="first_name" name="first_name" type="text" required
value="{{ old('first_name') }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('first_name') border-red-500 @enderror"
placeholder="{{ __('profile.first_name_placeholder') }}">
@error('first_name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Last Name Field -->
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">
{{ __('profile.last_name') }}
</label>
<input id="last_name" name="last_name" type="text" required
value="{{ old('last_name') }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('last_name') border-red-500 @enderror"
placeholder="{{ __('profile.last_name_placeholder') }}">
@error('last_name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Address Field -->
<div>
<label for="address" class="block text-sm font-medium text-gray-700">
{{ __('profile.address') }}
</label>
<textarea id="address" name="address" required rows="3"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('address') border-red-500 @enderror">{{ old('address') }}</textarea>
@error('address')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Phone Field -->
<div>
<label for="phone" class="block text-sm font-medium text-gray-700">
{{ __('profile.phone') }}
</label>
<input id="phone" name="phone" type="tel" required
value="{{ old('phone') }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('phone') border-red-500 @enderror"
placeholder="{{ __('profile.phone_placeholder') }}">
@error('phone')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Privacy Consent -->
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="privacy_consent" name="privacy_consent" type="checkbox" required
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded @error('privacy_consent') border-red-500 @enderror">
</div>
<div class="ml-3 text-sm">
<label for="privacy_consent" class="font-medium text-gray-700">
{{ __('auth.privacy_consent') }}
<a href="{{ route('datenschutz') }}" class="text-blue-600 hover:text-blue-500">{{ __('auth.privacy_policy') }}</a>.
</label>
@error('privacy_consent')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
<div>
<button type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-300">
{{ __('auth.register') }}
</button>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">
{{ __('auth.have_account') }}
<a href="{{ route('login') }}" class="font-medium text-blue-600 hover:text-blue-500">
{{ __('auth.login') }}
</a>
</p>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -1,13 +1,58 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zahlung erfolgreich - Fotospiel</title>
</head>
<body class="container mx-auto px-4 py-8 text-center">
<h1>Zahlung erfolgreich!</h1>
<p>Vielen Dank für Ihren Kauf. Ihr Konto wurde aktualisiert.</p>
<a href="/admin" class="bg-green-600 text-white px-4 py-2 rounded">Zum Admin-Dashboard</a>
</body>
</html>
@extends('marketing.layout')
@section('title', __('marketing.success.title'))
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
@auth
@if(auth()->user()->email_verified_at)
<script>
window.location.href = '/admin';
</script>
<div class="text-center">
<div class="spinner-border animate-spin inline-block w-8 h-8 border border-2 border-blue-600 border-t-transparent rounded-full" role="status">
<span class="sr-only">Loading...</span>
</div>
<p class="mt-2 text-gray-600">{{ __('marketing.success.redirecting') }}</p>
</div>
@else
<div class="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">
{{ __('marketing.success.verify_email') }}
</h2>
<p class="text-gray-600 mb-6">
{{ __('marketing.success.check_email') }}
</p>
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300">
{{ __('auth.resend_verification') }}
</button>
</form>
<p class="mt-4 text-sm text-gray-600">
{{ __('auth.have_account') }} <a href="{{ route('login') }}" class="text-blue-600 hover:text-blue-500">{{ __('auth.login') }}</a>
</p>
</div>
</div>
@endif
@else
<div class="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">
{{ __('marketing.success.complete_purchase') }}
</h2>
<p class="text-gray-600 mb-6">
{{ __('marketing.success.login_to_continue') }}
</p>
<a href="{{ route('login') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300 block mb-2">
{{ __('auth.login') }}
</a>
<p class="text-sm text-gray-600">
{{ __('auth.no_account') }} <a href="{{ route('register') }}" class="text-blue-600 hover:text-blue-500">{{ __('auth.register') }}</a>
</p>
</div>
</div>
@endauth
</div>
@endsection

View File

@@ -0,0 +1,192 @@
@extends('layouts.marketing')
@section('title', __('profile.edit_title'))
@section('content')
<div class="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-4xl mx-auto">
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ __('profile.personal_information') }}
</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
{{ __('profile.update_info') }}
</p>
</div>
<div class="border-t border-gray-200">
<form method="POST" action="{{ route('profile.update') }}" class="px-4 py-5 sm:p-6">
@csrf
@method('PATCH')
<!-- Name Field -->
<div class="grid grid-cols-6 gap-6">
<div class="col-span-6 sm:col-span-3">
<label for="name" class="block text-sm font-medium text-gray-700">
{{ __('auth.name') }}
</label>
<input type="text" name="name" id="name" value="{{ old('name', $user->name) }}"
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('name') border-red-500 @enderror"
required>
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Username Field -->
<div class="col-span-6 sm:col-span-3">
<label for="username" class="block text-sm font-medium text-gray-700">
{{ __('auth.username') }}
</label>
<input type="text" name="username" id="username" value="{{ old('username', $user->username) }}"
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('username') border-red-500 @enderror"
required>
@error('username')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
<div class="grid grid-cols-6 gap-6 mt-6">
<!-- Email Field -->
<div class="col-span-6 sm:col-span-3">
<label for="email" class="block text-sm font-medium text-gray-700">
{{ __('auth.email') }}
</label>
<input type="email" name="email" id="email" value="{{ old('email', $user->email) }}"
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('email') border-red-500 @enderror"
required>
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- First Name Field -->
<div class="col-span-6 sm:col-span-3">
<label for="first_name" class="block text-sm font-medium text-gray-700">
{{ __('profile.first_name') }}
</label>
<input type="text" name="first_name" id="first_name" value="{{ old('first_name', $user->first_name) }}"
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('first_name') border-red-500 @enderror"
required>
@error('first_name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
<div class="grid grid-cols-6 gap-6 mt-6">
<!-- Last Name Field -->
<div class="col-span-6 sm:col-span-3">
<label for="last_name" class="block text-sm font-medium text-gray-700">
{{ __('profile.last_name') }}
</label>
<input type="text" name="last_name" id="last_name" value="{{ old('last_name', $user->last_name) }}"
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('last_name') border-red-500 @enderror"
required>
@error('last_name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Phone Field -->
<div class="col-span-6 sm:col-span-3">
<label for="phone" class="block text-sm font-medium text-gray-700">
{{ __('profile.phone') }}
</label>
<input type="tel" name="phone" id="phone" value="{{ old('phone', $user->phone) }}"
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('phone') border-red-500 @enderror"
required>
@error('phone')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
<!-- Address Field -->
<div class="col-span-6 mt-6">
<label for="address" class="block text-sm font-medium text-gray-700">
{{ __('profile.address') }}
</label>
<textarea name="address" id="address" rows="3" class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('address') border-red-500 @enderror">{{ old('address', $user->address) }}</textarea>
@error('address')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
@if (session('status') == 'profile-updated')
<div class="col-span-6 mt-4 bg-green-50 border border-green-200 rounded-md p-4">
<p class="text-sm text-green-800">{{ __('profile.updated_success') }}</p>
</div>
@endif
<div class="flex justify-end mt-6">
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition duration-300">
{{ __('profile.save') }}
</button>
</div>
</form>
</div>
</div>
<!-- Password Update Section -->
<div class="mt-8 bg-white shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ __('profile.password') }}
</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
{{ __('profile.update_password') }}
</p>
</div>
<div class="border-t border-gray-200 px-4 py-5 sm:p-6">
<form method="POST" action="{{ route('profile.update-password') }}">
@csrf
@method('PATCH')
<!-- Current Password -->
<div class="mb-4">
<label for="current_password" class="block text-sm font-medium text-gray-700">
{{ __('profile.current_password') }}
</label>
<input type="password" name="current_password" id="current_password" required
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('current_password') border-red-500 @enderror">
@error('current_password')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- New Password -->
<div class="mb-4">
<label for="password" class="block text-sm font-medium text-gray-700">
{{ __('auth.password') }}
</label>
<input type="password" name="password" id="password" required
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md @error('password') border-red-500 @enderror">
@error('password')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Confirm New Password -->
<div class="mb-6">
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">
{{ __('auth.confirm_password') }}
</label>
<input type="password" name="password_confirmation" id="password_confirmation" required
class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md">
</div>
<div class="flex justify-end">
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition duration-300">
{{ __('profile.update_password') }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -9,6 +9,8 @@ use App\Http\Controllers\OAuthController;
use App\Http\Controllers\RevenueCatWebhookController;
use App\Http\Controllers\Api\PackageController;
use App\Http\Controllers\Api\TenantPackageController;
use App\Http\Controllers\StripeController;
use App\Http\Controllers\StripeWebhookController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->name('api.v1.')->group(function () {
@@ -40,7 +42,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->only(['index', 'show', 'destroy'])
->parameters(['events' => 'event:slug']);
Route::middleware('credit.check')->group(function () {
Route::middleware('package.check')->group(function () {
Route::post('events', [EventController::class, 'store'])->name('tenant.events.store');
Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update');
});

View File

@@ -6,14 +6,32 @@ use Inertia\Inertia;
// Marketing-Seite mit Locale-Prefix
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
Route::view('/', 'marketing')->name('marketing');
Route::view('/packages', 'marketing.packages')->name('packages');
Route::get('/occasions/{type}', function ($type) {
return view('marketing.occasions', ['type' => $type]);
})->name('occasions.type');
Route::get('/blog', [\App\Http\Controllers\MarketingController::class, 'blogIndex'])->name('blog');
Route::get('/blog/{post}', [\App\Http\Controllers\MarketingController::class, 'blogShow'])->name('blog.show');
Route::get('/register/{package_id?}', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'create'])->name('register');
Route::post('/register', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'store']);
});
// Packages Route (outside locale group for direct access)
Route::view('/packages', 'marketing.packages')->name('packages');
// Blog Routes (outside locale group for direct access)
Route::get('/blog', [\App\Http\Controllers\MarketingController::class, 'blogIndex'])->name('blog');
Route::get('/blog/{post}', [\App\Http\Controllers\MarketingController::class, 'blogShow'])->name('blog.show');
// Legal Pages
Route::get('/impressum', function () {
return view('legal.impressum');
})->name('impressum');
Route::get('/datenschutz', function () {
return view('legal.datenschutz');
})->name('datenschutz');
Route::get('/kontakt', function () {
return view('legal.kontakt');
})->name('kontakt');
Route::post('/kontakt', [\App\Http\Controllers\MarketingController::class, 'contact'])->name('kontakt.submit');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', function () {
return Inertia::render('dashboard');
@@ -30,17 +48,6 @@ Route::view('/pwa/{any?}', 'guest')->where('any', '.*');
// Minimal public API for Guest PWA (stateless; no CSRF)
Route::prefix('api/v1')->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class])->group(function () {
// Public legal pages (for marketing)
Route::get('/impressum', function () {
return view('legal.impressum');
})->name('impressum');
Route::get('/datenschutz', function () {
return view('legal.datenschutz');
})->name('datenschutz');
Route::get('/kontakt', function () {
return view('legal.kontakt');
})->name('kontakt');
Route::post('/kontakt', [\App\Http\Controllers\MarketingController::class, 'contact'])->name('kontakt.submit');
});
// Stripe webhooks (no CSRF, no auth)
@@ -66,8 +73,8 @@ Route::get('/super-admin/templates/emotions.csv', function () {
});
// Tenant Admin PWA shell
Route::view('/admin/{any?}', 'admin')->where('any', '.*');
Route::get('/admin/qr', [\App\Http\Controllers\Admin\QrController::class, 'png']);
Route::view('/admin/{any?}', 'admin')->middleware(['auth', 'verified', 'tenant'])->where('any', '.*');
Route::get('/admin/qr', [\App\Http\Controllers\Admin\QrController::class, 'png'])->middleware(['auth', 'verified', 'tenant']);
Route::get('/super-admin/templates/tasks.csv', function () {
$headers = [
'Content-Type' => 'text/csv',
@@ -82,13 +89,13 @@ Route::get('/super-admin/templates/tasks.csv', function () {
return response()->stream($callback, 200, $headers);
});
// E-Commerce Routen für Marketing
Route::get('/buy-credits/{package}', [\App\Http\Controllers\MarketingController::class, 'checkout'])->name('buy.credits');
Route::get('/checkout/{sessionId}', [\App\Http\Controllers\MarketingController::class, 'stripeCheckout']);
Route::get('/paypal-checkout/{package}', [\App\Http\Controllers\MarketingController::class, 'paypalCheckout']);
Route::get('/marketing/success/{package}', [\App\Http\Controllers\MarketingController::class, 'success'])->name('marketing.success');
Route::middleware('auth')->group(function () {
Route::get('/buy-packages/{package_id}', [\App\Http\Controllers\MarketingController::class, 'buyPackages'])->name('buy.packages');
Route::get('/profile', [\App\Http\Controllers\ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [\App\Http\Controllers\ProfileController::class, 'update'])->name('profile.update');
});
// E-Commerce Routen für Marketing
Route::get('/buy-credits/{package}', [\App\Http\Controllers\MarketingController::class, 'checkout'])->name('buy.credits');
Route::get('/checkout/{sessionId}', [\App\Http\Controllers\MarketingController::class, 'stripeCheckout']);
Route::get('/paypal-checkout/{package}', [\App\Http\Controllers\MarketingController::class, 'paypalCheckout']);
// Success view route (no controller needed, direct view)
Route::get('/marketing/success/{package_id?}', function ($packageId = null) {
return view('marketing.success', compact('packageId'));
})->name('marketing.success');

View File

@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature;
use App\Models\Package;
use App\Models\User;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\PackagePurchase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Illuminate\Support\Facades\Auth;
class PurchaseTest extends TestCase
{
use RefreshDatabase;
public function test_unauthenticated_buy_redirects_to_register()
{
$package = Package::factory()->create(['price' => 10]);
$response = $this->get(route('buy.packages', $package->id));
$response->assertRedirect(route('register', ['package_id' => $package->id]));
}
public function test_unverified_buy_redirects_to_verification()
{
$package = Package::factory()->create(['price' => 10]);
$user = User::factory()->create(['email_verified_at' => null]);
$tenant = Tenant::factory()->create(['user_id' => $user->id]);
Auth::login($user);
$response = $this->get(route('buy.packages', $package->id));
$response->assertRedirect(route('verification.notice'));
}
public function test_free_package_assigns_after_auth()
{
$freePackage = Package::factory()->create(['price' => 0]);
$user = User::factory()->create(['email_verified_at' => now()]);
$tenant = Tenant::factory()->create(['user_id' => $user->id]);
Auth::login($user);
$response = $this->get(route('buy.packages', $freePackage->id));
$response->assertRedirect('/admin');
$this->assertDatabaseHas('tenant_packages', [
'tenant_id' => $tenant->id,
'package_id' => $freePackage->id,
]);
$this->assertDatabaseHas('package_purchases', [
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $freePackage->id,
]);
}
public function test_paid_package_creates_stripe_session()
{
$paidPackage = Package::factory()->create(['price' => 10]);
$user = User::factory()->create(['email_verified_at' => now()]);
$tenant = Tenant::factory()->create(['user_id' => $user->id]);
Auth::login($user);
$response = $this->get(route('buy.packages', $paidPackage->id));
$response->assertStatus(302); // Redirect to Stripe
$this->assertStringContainsString('checkout.stripe.com', $response->headers->get('Location'));
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature;
use App\Models\Package;
use App\Models\User;
use App\Models\Tenant;
use App\Models\TenantPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use App\Mail\Welcome;
use Illuminate\Auth\Events\Registered;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_creates_user_and_tenant()
{
$freePackage = Package::factory()->create(['price' => 0]);
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'username' => 'testuser',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'first_name' => 'Test',
'last_name' => 'User',
'address' => 'Test Address',
'phone' => '123456789',
'privacy_consent' => true,
'package_id' => $freePackage->id,
]);
$response->assertRedirect(route('verification.notice'));
$this->assertDatabaseHas('users', [
'username' => 'testuser',
'email' => 'test@example.com',
'first_name' => 'Test',
'last_name' => 'User',
'address' => 'Test Address',
'phone' => '123456789',
]);
$user = User::where('email', 'test@example.com')->first();
$this->assertNotNull($user->tenant);
$this->assertDatabaseHas('tenants', [
'user_id' => $user->id,
'name' => 'Test User',
]);
$this->assertDatabaseHas('tenant_packages', [
'tenant_id' => $user->tenant->id,
'package_id' => $freePackage->id,
]);
}
public function test_registration_without_package()
{
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'username' => 'testuser2',
'email' => 'test2@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'first_name' => 'Test',
'last_name' => 'User',
'address' => 'Test Address',
'phone' => '123456789',
'privacy_consent' => true,
]);
$response->assertRedirect(route('verification.notice'));
$user = User::where('email', 'test2@example.com')->first();
$this->assertNotNull($user->tenant);
$this->assertDatabaseMissing('tenant_packages', [
'tenant_id' => $user->tenant->id,
]);
}
public function test_registration_validation_fails()
{
$response = $this->post(route('register.store'), [
'name' => '',
'username' => '',
'email' => 'invalid',
'password' => 'short',
'password_confirmation' => 'different',
'first_name' => '',
'last_name' => '',
'address' => '',
'phone' => '',
'privacy_consent' => false,
]);
$response->assertSessionHasErrors([
'name', 'username', 'email', 'password', 'first_name', 'last_name', 'address', 'phone', 'privacy_consent',
]);
}
public function test_registered_event_sends_welcome_email()
{
Mail::fake();
$freePackage = Package::factory()->create(['price' => 0]);
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'username' => 'testuser3',
'email' => 'test3@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'first_name' => 'Test',
'last_name' => 'User',
'address' => 'Test Address',
'phone' => '123456789',
'privacy_consent' => true,
'package_id' => $freePackage->id,
]);
Mail::assertQueued(Welcome::class, function ($mail) {
return $mail->to[0]['address'] === 'test3@example.com';
});
}
}