übergang auf pakete, integration von stripe und paypal, blog hinzugefügt.
This commit is contained in:
78
app/Console/Commands/MigrateLegacyPurchases.php
Normal file
78
app/Console/Commands/MigrateLegacyPurchases.php
Normal 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;
|
||||
}
|
||||
}
|
||||
9
app/Enums/PackageType.php
Normal file
9
app/Enums/PackageType.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PackageType: string
|
||||
{
|
||||
case ENDCUSTOMER = 'endcustomer';
|
||||
case RESELLER = 'reseller';
|
||||
}
|
||||
87
app/Filament/Pages/Auth/Login.php
Normal file
87
app/Filament/Pages/Auth/Login.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
20
app/Filament/Resources/PackageResource/Pages/EditPackage.php
Normal file
20
app/Filament/Resources/PackageResource/Pages/EditPackage.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
199
app/Filament/Resources/PurchaseResource.php
Normal file
199
app/Filament/Resources/PurchaseResource.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
119
app/Filament/Resources/TenantPackageResource.php
Normal file
119
app/Filament/Resources/TenantPackageResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
132
app/Filament/Resources/UserResource.php
Normal file
132
app/Filament/Resources/UserResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
34
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal 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');
|
||||
}
|
||||
}
|
||||
28
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
28
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal 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');
|
||||
}
|
||||
}
|
||||
41
app/Filament/SuperAdmin/Pages/Auth/EditProfile.php
Normal file
41
app/Filament/SuperAdmin/Pages/Auth/EditProfile.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
app/Filament/SuperAdmin/Pages/Auth/Login.php
Normal file
79
app/Filament/SuperAdmin/Pages/Auth/Login.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
94
app/Http/Controllers/Auth/MarketingRegisterController.php
Normal file
94
app/Http/Controllers/Auth/MarketingRegisterController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
$purchase = EventPurchase::create([
|
||||
if ($package->price == 0) {
|
||||
TenantPackage::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'events_purchased' => $pkg['events'],
|
||||
'amount' => 0,
|
||||
'currency' => 'EUR',
|
||||
'provider' => 'free',
|
||||
'status' => 'completed',
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
[
|
||||
'active' => true,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
]
|
||||
);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
235
app/Http/Controllers/PayPalController.php
Normal file
235
app/Http/Controllers/PayPalController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
67
app/Http/Controllers/ProfileController.php
Normal file
67
app/Http/Controllers/ProfileController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
107
app/Http/Controllers/StripeController.php
Normal file
107
app/Http/Controllers/StripeController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::error('Invalid JSON in Stripe webhook: ' . json_last_error_msg());
|
||||
return response('', 200);
|
||||
return response()->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
if ($event['type'] === 'checkout.session.completed') {
|
||||
$session = $event['data']['object'];
|
||||
$receiptId = $session['id'];
|
||||
|
||||
// Idempotency check
|
||||
if (EventPurchase::where('external_receipt_id', $receiptId)->exists()) {
|
||||
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;
|
||||
}
|
||||
|
||||
$tenantId = $session['metadata']['tenant_id'] ?? null;
|
||||
$userId = $metadata['user_id'] ?? null;
|
||||
$tenantId = $metadata['tenant_id'] ?? null;
|
||||
$packageId = $metadata['package_id'];
|
||||
$type = $metadata['type'] ?? 'endcustomer_event';
|
||||
|
||||
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::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);
|
||||
Log::error('No tenant_id found for Stripe payment intent: ' . $paymentIntent['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$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',
|
||||
// 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,
|
||||
]);
|
||||
|
||||
$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);
|
||||
if ($type === 'endcustomer_event') {
|
||||
// For event packages, assume event_id from metadata or handle separately
|
||||
// TODO: Link to specific event if provided
|
||||
}
|
||||
|
||||
return response('', 200);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
91
app/Http/Middleware/PackageMiddleware.php
Normal file
91
app/Http/Middleware/PackageMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
42
app/Mail/Welcome.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ class Event extends Model
|
||||
'date' => 'datetime',
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'name' => 'array',
|
||||
'description' => 'array',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -4,4 +4,5 @@ return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class,
|
||||
App\Providers\Filament\SuperAdminPanelProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
];
|
||||
|
||||
@@ -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
885
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
];
|
||||
|
||||
@@ -67,8 +67,8 @@ return [
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'hr',
|
||||
'image',
|
||||
//'hr',
|
||||
//'image',
|
||||
'italic',
|
||||
'link',
|
||||
'orderedList',
|
||||
|
||||
120
config/filament.php
Normal file
120
config/filament.php
Normal 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',
|
||||
|
||||
];
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -15,17 +15,39 @@ 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
resources/lang/de/auth.php
Normal file
35
resources/lang/de/auth.php
Normal 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',
|
||||
],
|
||||
];
|
||||
17
resources/lang/de/profile.php
Normal file
17
resources/lang/de/profile.php
Normal 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',
|
||||
];
|
||||
14
resources/views/emails/purchase.blade.php
Normal file
14
resources/views/emails/purchase.blade.php
Normal 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>
|
||||
14
resources/views/emails/welcome.blade.php
Normal file
14
resources/views/emails/welcome.blade.php
Normal 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>
|
||||
73
resources/views/layouts/marketing.blade.php
Normal file
73
resources/views/layouts/marketing.blade.php
Normal 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>© 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>
|
||||
@@ -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>
|
||||
40
resources/views/legal/kontakt.blade.php
Normal file
40
resources/views/legal/kontakt.blade.php
Normal 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
|
||||
@@ -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>© 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
|
||||
@@ -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 -->
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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>© 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
99
resources/views/marketing/profile.blade.php
Normal file
99
resources/views/marketing/profile.blade.php
Normal 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
|
||||
182
resources/views/marketing/register.blade.php
Normal file
182
resources/views/marketing/register.blade.php
Normal 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
|
||||
@@ -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
|
||||
192
resources/views/profile/edit.blade.php
Normal file
192
resources/views/profile/edit.blade.php
Normal 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
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
74
tests/Feature/PurchaseTest.php
Normal file
74
tests/Feature/PurchaseTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
130
tests/Feature/RegistrationTest.php
Normal file
130
tests/Feature/RegistrationTest.php
Normal 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';
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user