webseite funktioniert, pay sdk, blog backend funktioniert

This commit is contained in:
Codex Agent
2025-09-29 22:16:12 +02:00
parent e52a4005aa
commit 21c9391e2c
51 changed files with 2093 additions and 1293 deletions

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Filament\Blog\Resources;
use Illuminate\Support\Facades\Log;
use App\Filament\Blog\Resources\CategoryResource\Pages;
use App\Filament\Blog\Traits\HasContentEditor;
use App\Models\BlogCategory;
use Filament\Forms;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
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\Str;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
class CategoryResource extends Resource
{
use HasContentEditor;
protected static ?string $model = BlogCategory::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-list-bullet';
protected static ?string $navigationLabel = 'Kategorien';
protected static ?string $pluralLabel = 'Kategorien';
protected static ?string $modelLabel = 'Kategorie';
public static function form(Schema $schema): Schema
{
return $schema
->schema([
SchemaTabs::make('Übersetzungen')
->tabs([
SchemaTab::make('Deutsch')
->schema([
TextInput::make('name_de')
->label('Name')
->validationAttribute('name_de')
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $state) {
if (($get('slug') ?? '') !== Str::slug($state)) {
return;
}
$set('slug', Str::slug($state));
}),
MarkdownEditor::make('description_de')
->label('Beschreibung')
->validationAttribute('description_de'),
]),
SchemaTab::make('Englisch')
->schema([
TextInput::make('name_en')
->label('Name'),
MarkdownEditor::make('description_en')
->label('Beschreibung'),
]),
]),
TextInput::make('slug')
->label('Slug')
->required()
->unique(BlogCategory::class, 'slug', ignoreRecord: true),
Section::make('Sichtbarkeit')
->schema([
Toggle::make('is_visible')
->label('Sichtbar für Gäste')
->default(true),
]),
Section::make('Metadaten')
->schema([
TextEntry::make('created_at')
->label('Erstellt am')
->default('—')
->state(fn (?BlogCategory $record) => $record?->created_at?->diffForHumans()),
TextEntry::make('updated_at')
->label('Zuletzt geändert')
->default('—')
->state(fn (?BlogCategory $record) => $record?->updated_at?->diffForHumans()),
]),
])
->columns(3);
}
public static function mutateFormDataBeforeFill(array $data): array
{
$nameJson = $data['name'] ?? '[]';
$nameArray = json_decode($nameJson, true) ?: [];
$data['name_de'] = $nameArray['de'] ?? '';
$data['name_en'] = $nameArray['en'] ?? '';
$descJson = $data['description'] ?? '[]';
$descArray = json_decode($descJson, true) ?: [];
$data['description_de'] = $descArray['de'] ?? '';
$data['description_en'] = $descArray['en'] ?? '';
\Illuminate\Support\Facades\Log::info('BeforeFill Description Extraction:', [
'descJson' => $descJson,
'descArray' => $descArray,
'description_de' => $data['description_de'],
'description_en' => $data['description_en'],
]);
return $data;
}
public static function mutateFormDataBeforeCreate(array $data): array
{
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Input Data:', ['data' => $data]);
$nameData = [
'de' => $data['name_de'] ?? '',
'en' => $data['name_en'] ?? '',
];
$data['name'] = json_encode($nameData);
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Name JSON:', ['name' => $nameData]);
$descData = [
'de' => $data['description_de'] ?? '',
'en' => $data['description_en'] ?? '',
];
$data['description'] = json_encode($descData);
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Description JSON:', ['description' => $descData]);
unset($data['name_de'], $data['name_en'], $data['description_de'], $data['description_en']);
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Final Data:', $data);
return $data;
}
public static function mutateFormDataBeforeSave(array $data): array
{
$transformed = static::mutateFormDataBeforeCreate($data);
return $transformed;
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Name (DE)')
->getStateUsing(fn ($record) => json_decode($record->name ?? '[]', true)['de'] ?? '—')
->searchable()
->sortable(),
TextColumn::make('slug')
->label('Slug')
->searchable()
->sortable(),
IconColumn::make('is_visible')
->label('Sichtbar')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle'),
TextColumn::make('updated_at')
->label('Zuletzt geändert')
->date()
->sortable(),
])
->filters([
//
])
->actions([
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCategories::route('/'),
'create' => Pages\CreateCategory::route('/create'),
'edit' => Pages\EditCategory::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Filament\Blog\Resources\CategoryResource\Pages;
use App\Filament\Blog\Resources\CategoryResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCategory extends CreateRecord
{
protected static string $resource = CategoryResource::class;
protected function getFormValidationRules(): array
{
return [
'name_de' => 'required|string|max:255',
'description_de' => 'nullable|string',
'name_en' => 'nullable|string|max:255',
'description_en' => 'nullable|string',
'slug' => 'required|string|max:255|unique:blog_categories,slug',
'is_visible' => 'boolean',
];
}
protected function store()
{
$state = $this->form->getState();
$data = $state['data'] ?? $state;
$data = \App\Filament\Blog\Resources\CategoryResource::mutateFormDataBeforeCreate($data);
$this->record = static::getResource()::getModel()::create($data);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Filament\Blog\Resources\CategoryResource\Pages;
use App\Filament\Blog\Resources\CategoryResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCategory extends EditRecord
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function getFormValidationRules(): array
{
return [
'name_de' => 'required|string|max:255',
'description_de' => 'nullable|string',
'name_en' => 'nullable|string|max:255',
'description_en' => 'nullable|string',
'slug' => 'required|string|max:255|unique:blog_categories,slug,' . $this->record->id,
'is_visible' => 'boolean',
];
}
public function mount($record): void
{
parent::mount($record);
$data = $this->record->toArray();
$data = \App\Filament\Blog\Resources\CategoryResource::mutateFormDataBeforeFill($data);
$this->form->fill($data);
}
public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void
{
$state = $this->form->getState();
\Illuminate\Support\Facades\Log::info('EditCategory Save - Full State:', $state);
$data = $state['data'] ?? $state;
$data = \App\Filament\Blog\Resources\CategoryResource::mutateFormDataBeforeSave($data);
$this->record->update($data);
}
}

View File

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

View File

@@ -0,0 +1,304 @@
<?php
namespace App\Filament\Blog\Resources;
use App\Filament\Blog\Resources\PostResource\Pages;
use App\Filament\Blog\Traits\HasContentEditor;
use App\Models\BlogPost;
use App\Models\BlogCategory;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Str;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
class PostResource extends Resource
{
use HasContentEditor;
protected static ?string $model = BlogPost::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationLabel = 'Beiträge';
protected static ?string $pluralLabel = 'Beiträge';
protected static ?string $modelLabel = 'Beitrag';
public static function form(Schema $schema): Schema
{
return $schema
->schema([
SchemaTabs::make('Übersetzungen')
->tabs([
SchemaTab::make('Deutsch')
->schema([
TextInput::make('title_de')
->label('Titel')
->required()
->maxLength(255)
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
if (($get('slug') ?? '') !== Str::slug($state)) {
return;
}
$set('slug', Str::slug($state));
}),
MarkdownEditor::make('content_de')
->label('Inhalt')
->required()
->columnSpanFull(),
TextInput::make('excerpt_de')
->label('Auszug')
->maxLength(255),
TextInput::make('meta_title_de')
->label('Meta-Titel')
->maxLength(255),
Textarea::make('meta_description_de')
->label('Meta-Beschreibung')
->maxLength(65535)
->columnSpanFull(),
])
->columns(2),
SchemaTab::make('Englisch')
->schema([
TextInput::make('title_en')
->label('Titel')
->maxLength(255),
MarkdownEditor::make('content_en')
->label('Inhalt')
->columnSpanFull(),
TextInput::make('excerpt_en')
->label('Auszug')
->maxLength(255),
TextInput::make('meta_title_en')
->label('Meta-Titel')
->maxLength(255),
Textarea::make('meta_description_en')
->label('Meta-Beschreibung')
->maxLength(65535)
->columnSpanFull(),
])
->columns(2),
]),
TextInput::make('slug')
->label('Slug')
->required()
->unique(BlogPost::class, 'slug', ignoreRecord: true)
->maxLength(255),
Section::make('Bild und Kategorie')
->schema([
FileUpload::make('featured_image')
->label('Featured Image')
->image()
->directory('blog')
->visibility('public'),
Select::make('category_id')
->label('Kategorie')
->relationship('category', 'name_de')
->required()
->preload()
->createOptionForm([
TextInput::make('name_de')
->label('Name (DE)')
->required()
->maxLength(255)
->afterStateUpdated(fn (Set $set, $state) => $set('name_en', $state)),
TextInput::make('slug')
->label('Slug')
->required()
->unique(\App\Models\BlogCategory::class, 'slug', ignoreRecord: true)
->maxLength(255),
]),
])
->columns(2),
Section::make('Veröffentlichung')
->schema([
Toggle::make('is_published')
->label('Veröffentlicht'),
DateTimePicker::make('published_at')
->label('Veröffentlicht am')
->displayFormat('Y-m-d H:i:s')
->default(now()),
]),
]);
}
public static function mutateFormDataBeforeCreate(array $data): array
{
$data['translations'] = [
'title' => [
'de' => $data['title_de'] ?? '',
'en' => $data['title_en'] ?? '',
],
'content' => [
'de' => $data['content_de'] ?? '',
'en' => $data['content_en'] ?? '',
],
'excerpt' => [
'de' => $data['excerpt_de'] ?? '',
'en' => $data['excerpt_en'] ?? '',
],
'meta_title' => [
'de' => $data['meta_title_de'] ?? '',
'en' => $data['meta_title_en'] ?? '',
],
'meta_description' => [
'de' => $data['meta_description_de'] ?? '',
'en' => $data['meta_description_en'] ?? '',
],
];
unset($data['title_de'], $data['title_en'], $data['content_de'], $data['content_en'], $data['excerpt_de'], $data['excerpt_en'], $data['meta_title_de'], $data['meta_title_en'], $data['meta_description_de'], $data['meta_description_en']);
return $data;
}
public static function mutateFormDataBeforeFill(array $data): array
{
$record = static::getModel()::find(request()?->route()?->parameter('record') ?? request()?->input('record_id') ?? null);
if (!$record) {
return $data;
}
$data['title_de'] = $record->getTranslation('title', 'de');
$data['title_en'] = $record->getTranslation('title', 'en');
$data['content_de'] = $record->getTranslation('content', 'de');
$data['content_en'] = $record->getTranslation('content', 'en');
$data['excerpt_de'] = $record->getTranslation('excerpt', 'de');
$data['excerpt_en'] = $record->getTranslation('excerpt', 'en');
$data['meta_title_de'] = $record->getTranslation('meta_title', 'de');
$data['meta_title_en'] = $record->getTranslation('meta_title', 'en');
$data['meta_description_de'] = $record->getTranslation('meta_description', 'de');
$data['meta_description_en'] = $record->getTranslation('meta_description', 'en');
return $data;
}
public static function mutateFormDataBeforeSave(array $data): array
{
$data['translations'] = [
'title' => [
'de' => $data['title_de'] ?? '',
'en' => $data['title_en'] ?? '',
],
'content' => [
'de' => $data['content_de'] ?? '',
'en' => $data['content_en'] ?? '',
],
'excerpt' => [
'de' => $data['excerpt_de'] ?? '',
'en' => $data['excerpt_en'] ?? '',
],
'meta_title' => [
'de' => $data['meta_title_de'] ?? '',
'en' => $data['meta_title_en'] ?? '',
],
'meta_description' => [
'de' => $data['meta_description_de'] ?? '',
'en' => $data['meta_description_en'] ?? '',
],
];
unset($data['title_de'], $data['title_en'], $data['content_de'], $data['content_en'], $data['excerpt_de'], $data['excerpt_en'], $data['meta_title_de'], $data['meta_title_en'], $data['meta_description_de'], $data['meta_description_en']);
return $data;
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label('Titel (DE)')
->getStateUsing(fn ($record) => $record->getTranslation('title', 'de'))
->searchable()
->sortable(),
TextColumn::make('category.name_de')
->label('Kategorie')
->badge()
->color('primary'),
IconColumn::make('is_published')
->label('Veröffentlicht')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle'),
TextColumn::make('published_at')
->label('Veröffentlicht am')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->label('Erstellt am')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TernaryFilter::make('is_published')
->label('Veröffentlicht'),
])
->actions([
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'view' => Pages\ViewPost::route('/{record}'),
'edit' => Pages\EditPost::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Filament\Blog\Traits;
use Filament\Forms\Components\MarkdownEditor;
trait HasContentEditor
{
public static function getContentEditor(string $field)
{
return MarkdownEditor::make($field)
->label('Inhalt')
->columnSpanFull()
->toolbarButtons(config('filament-blog.toolbar_buttons', [
'bold',
'italic',
'underline',
'strike',
'bulletList',
'orderedList',
'link',
'table',
'codeBlock',
'h1',
'h2',
'h3',
]));
}
}

View File

@@ -93,6 +93,135 @@ class PackageResource extends Resource
]);
}
public static function featuresToRepeaterItems(mixed $features): array
{
if (is_string($features)) {
$decoded = json_decode($features, true);
if (is_string($decoded)) {
$decoded = json_decode($decoded, true);
}
$features = json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}
if ($features === null) {
return [];
}
if (! is_array($features)) {
return [];
}
if (! array_is_list($features)) {
return collect($features)
->map(function ($value, $key) {
return [
'key' => (string) $key,
'value' => is_bool($value) ? ($value ? 'true' : 'false') : (string) $value,
];
})
->values()
->all();
}
return collect($features)
->map(function ($item) {
if (is_array($item)) {
return [
'key' => (string) ($item['key'] ?? ''),
'value' => (string) ($item['value'] ?? ''),
];
}
return [
'key' => (string) $item,
'value' => 'true',
];
})
->values()
->all();
}
public static function featuresFromRepeaterItems(mixed $items): array
{
if (! is_array($items)) {
return [];
}
$features = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$key = isset($item['key']) ? trim((string) $item['key']) : '';
if ($key === '') {
continue;
}
$value = $item['value'] ?? true;
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
$value = true;
} elseif (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
$value = false;
}
}
if (is_array($value)) {
$value = $value['value'] ?? $value['enabled'] ?? true;
}
$features[$key] = (bool) $value;
}
return $features;
}
public static function formatFeaturesForDisplay(mixed $features): string
{
$map = $features;
if (! is_array($map)) {
if (is_string($map)) {
$decoded = json_decode($map, true);
if (is_string($decoded)) {
$decoded = json_decode($decoded, true);
}
$map = json_last_error() === JSON_ERROR_NONE ? $decoded : [];
} else {
$map = [];
}
}
if (! array_is_list($map)) {
return collect($map)
->filter(fn ($value) => (bool) $value)
->keys()
->implode(', ');
}
return collect($map)
->map(function ($item) {
if (is_array($item)) {
return (string) ($item['key'] ?? '');
}
return (string) $item;
})
->filter()
->implode(', ');
}
public static function table(Table $table): Table
{
return $table
@@ -119,6 +248,7 @@ class PackageResource extends Resource
->color('primary'),
TextColumn::make('features')
->label('Features')
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state))
->limit(50),
])
->filters([
@@ -150,4 +280,4 @@ class PackageResource extends Resource
'edit' => Pages\EditPackage::route('/{record}/edit'),
];
}
}
}

View File

@@ -8,4 +8,12 @@ use Filament\Resources\Pages\CreateRecord;
class CreatePackage extends CreateRecord
{
protected static string $resource = PackageResource::class;
}
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['features'] = PackageResource::featuresFromRepeaterItems($data['features'] ?? []);
return $data;
}
}

View File

@@ -17,4 +17,19 @@ class EditPackage extends EditRecord
Actions\DeleteAction::make(),
];
}
}
protected function mutateFormDataBeforeFill(array $data): array
{
$data['features'] = PackageResource::featuresToRepeaterItems($data['features'] ?? null);
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
$data['features'] = PackageResource::featuresFromRepeaterItems($data['features'] ?? []);
return $data;
}
}

View File

@@ -12,12 +12,12 @@ 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\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;

View File

@@ -38,8 +38,7 @@ 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'))
@@ -87,8 +86,7 @@ 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(),
@@ -183,8 +181,7 @@ class TenantResource extends Resource
public static function getRelations(): array
{
\Illuminate\Support\Facades\Log::info('TenantResource getRelations() method called');
return [
TenantPackagesRelationManager::class,
PackagePurchasesRelationManager::class,

View File

@@ -10,9 +10,9 @@ use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Filters\SelectFilter;
@@ -140,4 +140,5 @@ class PackagePurchasesRelationManager extends RelationManager
]),
]);
}
}
}

View File

@@ -10,9 +10,9 @@ use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -128,3 +128,5 @@ class PurchasesRelationManager extends RelationManager
]);
}
}

View File

@@ -12,11 +12,11 @@ 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\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -129,4 +129,5 @@ class UserResource extends Resource
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}
}

View File

@@ -10,15 +10,14 @@ use Stripe\Stripe;
use Stripe\Checkout\Session;
use Stripe\StripeClient;
use Exception;
use PayPal\Api\Amount;
use PayPal\Api\Payer;
use PayPal\Api\Payment;
use PayPal\Api\RedirectUrls;
use PayPal\Api\Transaction;
use PayPal\Rest\ApiContext;
use PayPal\Auth\OAuthTokenCredential;
use PayPal\PayPalHttp\Client;
use PayPal\PayPalHttp\HttpException;
use PayPal\Checkout\Orders\OrdersCreateRequest;
use PayPal\Checkout\Orders\OrdersCaptureRequest;
use PayPal\Checkout\Orders\OrdersGetRequest;
use PayPal\Checkout\Orders\Order;
use App\Models\Tenant;
use App\Models\EventPurchase;
use App\Models\BlogPost;
use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\PackagePurchase;
@@ -107,6 +106,10 @@ class MarketingController extends Controller
return redirect('/admin')->with('success', __('marketing.packages.free_assigned'));
}
if ($package->type === 'reseller') {
return $this->stripeSubscription($request, $packageId);
}
if ($request->input('provider') === 'paypal') {
return $this->paypalCheckout($request, $packageId);
}
@@ -151,7 +154,7 @@ class MarketingController extends Controller
}
/**
* PayPal checkout with auth metadata.
* PayPal checkout with v2 Orders API (one-time payment).
*/
public function paypalCheckout(Request $request, $packageId)
{
@@ -159,78 +162,228 @@ class MarketingController extends Controller
$user = Auth::user();
$tenant = $user->tenant;
$apiContext = new ApiContext(
new OAuthTokenCredential(
config('services.paypal.client_id'),
config('services.paypal.secret')
)
);
$client = Client::create([
'clientId' => config('services.paypal.client_id'),
'clientSecret' => config('services.paypal.secret'),
'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live',
]);
$payment = new Payment();
$payer = new Payer();
$payer->setPaymentMethod('paypal');
$ordersController = $client->orders();
$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([
$metadata = 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);
$createRequest = new OrdersCreateRequest();
$createRequest->prefer('return=representation');
$createRequest->body = [
"intent" => "CAPTURE",
"purchase_units" => [[
"amount" => [
"currency_code" => "EUR",
"value" => number_format($package->price, 2, '.', ''),
],
"description" => "Package: " . $package->name,
"custom_id" => $metadata,
]],
"application_context" => [
"return_url" => route('marketing.success', $packageId),
"cancel_url" => route('packages'),
],
];
try {
$payment->create($apiContext);
$response = $ordersController->createOrder($createRequest);
$order = $response->result;
session(['paypal_payment_id' => $payment->getId()]);
session(['paypal_order_id' => $order->id]);
return redirect($payment->getApprovalLink());
foreach ($order->links as $link) {
if ($link->rel === 'approve') {
return redirect($link->href);
}
}
throw new Exception('No approve link found');
} catch (HttpException $e) {
Log::error('PayPal Orders API error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
} catch (\Exception $e) {
Log::error('PayPal checkout error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
}
}
/**
* Stripe subscription checkout for reseller packages.
*/
public function stripeSubscription(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' => $package->name . ' (Annual Subscription)',
],
'unit_amount' => $package->price * 100,
'recurring' => [
'interval' => 'year',
'interval_count' => 1,
],
],
'quantity' => 1,
]],
'mode' => 'subscription',
'success_url' => route('marketing.success', $packageId),
'cancel_url' => route('packages'),
'metadata' => [
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
'subscription' => 'true',
],
]);
return redirect($session->url, 303);
}
public function stripeCheckout($sessionId)
{
// Handle Stripe success
return view('marketing.success', ['provider' => 'Stripe']);
}
/**
* Handle success after payment (capture PayPal, redirect if verified).
*/
public function success(Request $request, $packageId = null)
{
if (session('paypal_order_id')) {
$orderId = session('paypal_order_id');
$client = Client::create([
'clientId' => config('services.paypal.client_id'),
'clientSecret' => config('services.paypal.secret'),
'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live',
]);
$ordersController = $client->orders();
$captureRequest = new OrdersCaptureRequest($orderId);
$captureRequest->prefer('return=minimal');
try {
$captureResponse = $ordersController->captureOrder($captureRequest);
$capture = $captureResponse->result;
if ($capture->status === 'COMPLETED') {
$customId = $capture->purchaseUnits[0]->customId ?? null;
if ($customId) {
$metadata = json_decode($customId, true);
$package = Package::find($metadata['package_id']);
$tenant = Tenant::find($metadata['tenant_id']);
if ($package && $tenant) {
TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'active' => true,
'purchased_at' => now(),
'expires_at' => now()->addYear(), // One-time as annual for reseller too
]
);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => 'paypal',
'price' => $package->price,
'type' => $package->type,
'purchased_at' => now(),
'refunded' => false,
]);
session()->forget('paypal_order_id');
$request->session()->flash('success', __('marketing.packages.purchased_successfully', ['name' => $package->name]));
}
}
} else {
Log::error('PayPal capture failed: ' . $capture->status);
$request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.');
}
} catch (HttpException $e) {
Log::error('PayPal capture error: ' . $e->getMessage());
$request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.');
} catch (\Exception $e) {
Log::error('PayPal success error: ' . $e->getMessage());
$request->session()->flash('error', 'Fehler beim Abschließen der Zahlung.');
}
}
// Common logic: Redirect to admin if verified
if (Auth::check() && Auth::user()->email_verified_at) {
return redirect('/admin')->with('success', __('marketing.success.welcome'));
}
return view('marketing.success', compact('packageId'));
}
public function blogIndex(Request $request)
{
$locale = $request->get('locale', app()->getLocale());
$posts = \Stephenjude\FilamentBlog\Models\Post::query()
->where('is_published', true)
Log::info('Blog Index Debug - Initial', [
'locale' => $locale,
'full_url' => $request->fullUrl()
]);
$query = BlogPost::query()
->whereHas('category', function ($query) {
$query->where('slug', 'blog');
});
$totalWithCategory = $query->count();
Log::info('Blog Index Debug - With Category', ['count' => $totalWithCategory]);
$query->where('is_published', true)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->whereJsonContains("translations->locale->title->{$locale}", true)
->orderBy('published_at', 'desc')
->where('published_at', '<=', now());
$totalPublished = $query->count();
Log::info('Blog Index Debug - Published', ['count' => $totalPublished]);
$query->whereJsonContains("translations->locale->title->{$locale}", true);
$totalWithTranslation = $query->count();
Log::info('Blog Index Debug - With Translation', ['count' => $totalWithTranslation, 'locale' => $locale]);
$posts = $query->orderBy('published_at', 'desc')
->paginate(8);
Log::info('Blog Index Debug - Final Posts', ['count' => $posts->count(), 'total' => $posts->total()]);
return view('marketing.blog', compact('posts'));
}
public function blogShow($slug)
{
$locale = app()->getLocale();
$post = \Stephenjude\FilamentBlog\Models\Post::query()
$post = BlogPost::query()
->whereHas('category', function ($query) {
$query->where('slug', 'blog');
})
->where('slug', $slug)
->where('is_published', true)
->whereNotNull('published_at')
@@ -240,4 +393,24 @@ class MarketingController extends Controller
return view('marketing.blog-show', compact('post'));
}
public function packagesIndex()
{
$endcustomerPackages = Package::where('type', 'endcustomer')->orderBy('price')->get();
$resellerPackages = Package::where('type', 'reseller')->orderBy('price')->get();
return view('marketing.packages', compact('endcustomerPackages', 'resellerPackages'));
}
public function occasionsType($locale, $type)
{
$validTypes = ['weddings', 'birthdays', 'corporate-events', 'family-celebrations'];
if (!in_array($type, $validTypes)) {
abort(404, 'Invalid occasion type');
}
return view('marketing.occasions', ['type' => $type]);
}
}

View File

@@ -4,8 +4,13 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\EventPurchase;
use PayPal\PayPalHttp\Client;
use PayPal\Checkout\Orders\OrdersGetRequest;
use App\Models\TenantPackage;
use App\Models\PackagePurchase;
use App\Models\Package;
use App\Models\Tenant;
use Exception;
class PayPalWebhookController extends Controller
{
@@ -13,72 +18,114 @@ class PayPalWebhookController extends Controller
{
$input = $request->all();
$ipnMessage = $input['ipn_track_id'] ?? null;
$payerEmail = $input['payer_email'] ?? null;
$paymentStatus = $input['payment_status'] ?? null;
$mcGross = $input['mc_gross'] ?? 0;
$custom = $input['custom'] ?? null;
$verification = $this->verifyIPN($request);
if ($paymentStatus === 'Completed' && $mcGross > 0) {
// Verify IPN with PayPal (simplified; use SDK for full verification)
// $verified = $this->verifyIPN($input);
if (!$verification) {
Log::warning('PayPal IPN verification failed', ['ipn_track_id' => $ipnMessage]);
return response('Invalid IPN', 400);
}
// 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;
$eventType = $input['payment_status'] ?? null;
$customId = $input['custom'] ?? null;
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 (!$eventType || !$customId) {
Log::warning('Missing event type or custom ID in PayPal IPN', ['input' => $input]);
return response('Invalid data', 400);
}
if ($eventType !== 'Completed') {
Log::info('Non-completed PayPal event ignored', ['event' => $eventType, 'ipn_track_id' => $ipnMessage]);
return response('OK', 200);
}
try {
$metadata = json_decode($customId, true);
if (!$metadata || !isset($metadata['tenant_id'], $metadata['package_id'])) {
throw new Exception('Invalid metadata');
}
if (!$tenantId || !$packageId) {
Log::error('Missing tenant or package in PayPal IPN custom data');
$tenant = Tenant::find($metadata['tenant_id']);
$package = Package::find($metadata['package_id']);
if (!$tenant || !$package) {
throw new Exception('Tenant or package not found');
}
// Idempotent: Check if already processed
$existingPurchase = PackagePurchase::where('tenant_id', $tenant->id)
->where('package_id', $package->id)
->where('provider_id', 'paypal')
->where('purchased_at', '>=', now()->subDay()) // Recent to avoid duplicates
->first();
if ($existingPurchase) {
Log::info('PayPal purchase already processed', ['purchase_id' => $existingPurchase->id]);
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',
// Activate package
TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'active' => true,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
]
);
// Log purchase
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => 'paypal',
'price' => $package->price,
'type' => $package->type,
'purchased_at' => now(),
'refunded' => false,
]);
// 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(),
]
);
}
$tenant->update(['subscription_status' => 'active']);
Log::info('PayPal IPN processed for tenant ' . $tenantId . ', package ' . $packageId, $input);
Log::info('PayPal purchase processed successfully', [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'ipn_track_id' => $ipnMessage,
]);
return response('OK', 200);
} catch (Exception $e) {
Log::error('PayPal webhook processing error: ' . $e->getMessage(), [
'input' => $input,
'ipn_track_id' => $ipnMessage,
]);
return response('Error', 500);
}
}
private function verifyIPN(Request $request)
{
$rawBody = $request->getContent();
$params = $request->all();
// For sandbox, post to PayPal verify endpoint
$verifyParams = array_merge($params, ['cmd' => '_notify-validate']);
$response = file_get_contents('https://ipnpb.paypal.com/cgi-bin/webscr', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-type: application/x-www-form-urlencoded',
'content' => http_build_query($verifyParams),
],
]));
if ($response === false) {
Log::error('PayPal IPN verification request failed');
return false;
}
return response('OK', 200);
}
private function verifyIPN($input)
{
// Use PayPal SDK to verify
// Return true/false
return true; // Placeholder
return trim($response) === 'VERIFIED';
}
}

View File

@@ -30,6 +30,13 @@ class StripeWebhookController extends Controller
}
switch ($event['type']) {
case 'checkout.session.completed':
$session = $event['data']['object'];
if ($session['mode'] === 'subscription' && isset($session['metadata']['subscription']) && $session['metadata']['subscription'] === 'true') {
$this->handleSubscriptionStarted($session);
}
break;
case 'payment_intent.succeeded':
$paymentIntent = $event['data']['object'];
$this->handlePaymentIntentSucceeded($paymentIntent);
@@ -167,4 +174,74 @@ class StripeWebhookController extends Controller
// TODO: Deactivate package or notify tenant
// e.g., TenantPackage::where('provider_id', $subscription)->update(['active' => false]);
}
private function handleSubscriptionStarted($session)
{
$metadata = $session['metadata'];
if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) {
Log::warning('Missing metadata in Stripe checkout session: ' . $session['id']);
return;
}
$userId = $metadata['user_id'] ?? null;
$tenantId = $metadata['tenant_id'] ?? null;
$packageId = $metadata['package_id'];
$type = $metadata['type'] ?? 'reseller_subscription';
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 checkout session: ' . $session['id']);
return;
}
$subscriptionId = $session['subscription']['id'] ?? null;
if (!$subscriptionId) {
Log::error('No subscription ID in checkout session: ' . $session['id']);
return;
}
// Activate TenantPackage for initial subscription
\App\Models\TenantPackage::updateOrCreate(
[
'tenant_id' => $tenantId,
'package_id' => $packageId,
],
[
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => true,
]
);
// Create initial PackagePurchase
\App\Models\PackagePurchase::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'provider_id' => $subscriptionId,
'price' => $session['amount_total'] / 100,
'type' => $type,
'purchased_at' => now(),
'refunded' => false,
]);
// Update tenant subscription fields if needed
$tenant = \App\Models\Tenant::find($tenantId);
if ($tenant) {
$tenant->update([
'subscription_id' => $subscriptionId,
'subscription_status' => 'active',
]);
}
Log::info('Initial subscription activated via Stripe checkout session: ' . $session['id'] . ' for tenant ' . $tenantId);
}
}

View File

@@ -2,24 +2,44 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Translatable\HasTranslations;
use Stephenjude\FilamentBlog\Models\Category as BaseCategory;
use Illuminate\Support\Facades\Log;
class BlogCategory extends BaseCategory
class BlogCategory extends Model
{
use HasFactory, SoftDeletes, HasTranslations;
use HasFactory, SoftDeletes;
protected $translatable = [
'name',
'description',
];
protected $table = 'blog_categories';
protected $fillable = [
'slug',
'is_visible',
'translations',
'name',
'description',
];
protected $casts = [
'is_visible' => 'boolean',
'name' => 'array',
'description' => 'array',
];
public function posts(): HasMany
{
return $this->hasMany(BlogPost::class, 'blog_category_id');
}
public function scopeIsVisible(Builder $query)
{
return $query->where('is_visible', true);
}
public function scopeIsInvisible(Builder $query)
{
return $query->where('is_visible', false);
}
}

View File

@@ -2,16 +2,21 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Spatie\Translatable\HasTranslations;
use Stephenjude\FilamentBlog\Models\Post as BasePost;
class BlogPost extends BasePost
class BlogPost extends Model
{
use HasFactory, SoftDeletes, HasTranslations;
protected $table = 'blog_posts';
protected $translatable = [
'title',
'excerpt',
@@ -21,7 +26,6 @@ class BlogPost extends BasePost
];
protected $fillable = [
'blog_author_id',
'blog_category_id',
'slug',
'banner',
@@ -29,4 +33,33 @@ class BlogPost extends BasePost
'is_published',
'translations',
];
protected $casts = [
'published_at' => 'date',
'is_published' => 'boolean',
];
protected $appends = [
'banner_url',
];
public function bannerUrl(): Attribute
{
return Attribute::get(fn () => $this->banner ? asset(Storage::url($this->banner)) : '');
}
public function scopePublished(Builder $query)
{
return $query->whereNotNull('published_at')->where('is_published', true);
}
public function scopeDraft(Builder $query)
{
return $query->whereNull('published_at');
}
public function category(): BelongsTo
{
return $this->belongsTo(BlogCategory::class, 'blog_category_id');
}
}

View File

@@ -5,10 +5,10 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Tags\Tag;
use Spatie\Translatable\HasTranslations;
use Stephenjude\FilamentBlog\Models\Tag as BaseTag;
class BlogTag extends BaseTag
class BlogTag extends Tag
{
use HasFactory, SoftDeletes, HasTranslations;

View File

@@ -40,7 +40,32 @@ class Package extends Model
'features' => 'array',
];
// features handled by $casts = ['features' => 'array']
protected function features(): Attribute
{
return Attribute::make(
get: function ($value) {
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
if (is_string($decoded)) {
$decoded = json_decode($decoded, true);
}
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
return $decoded;
}
}
return [];
},
);
}
public function eventPackages(): HasMany
{
@@ -77,4 +102,4 @@ class Package extends Model
'max_events_per_year' => $this->max_events_per_year,
];
}
}
}

View File

@@ -26,17 +26,17 @@ use App\Models\BlogCategory;
use App\Models\BlogTag;
use App\Filament\Widgets\PlatformStatsWidget;
use App\Filament\Widgets\TopTenantsByUploads;
use Stephenjude\FilamentBlog\Filament\Resources\CategoryResource;
use Stephenjude\FilamentBlog\Filament\Resources\PostResource;
use Stephenjude\FilamentBlog\BlogPlugin;
use App\Filament\Blog\Resources\PostResource;
use App\Filament\Blog\Resources\CategoryResource;
use App\Filament\Blog\Resources\AuthorResource;
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')
@@ -50,9 +50,9 @@ class SuperAdminPanelProvider extends PanelProvider
Pages\Dashboard::class,
])
->login(\App\Filament\SuperAdmin\Pages\Auth\Login::class)
->plugin(
/*->plugin(
BlogPlugin::make()
)
)*/
->profile()
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
@@ -76,8 +76,9 @@ class SuperAdminPanelProvider extends PanelProvider
Authenticate::class,
])
->resources([
// Temporär deaktiviert: TenantResource - verdächtigt für frühen Fehler
// TenantResource::class,
PostResource::class,
CategoryResource::class,
LegalPageResource::class,
])
->authGuard('web')

View File

@@ -17,7 +17,6 @@
"paypal/paypal-server-sdk": "^1.1",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-translatable": "^6.11",
"stephenjude/filament-blog": "*",
"stripe/stripe-php": "*"
},
"require-dev": {

830
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c7c9c8d3a298a4a78d257a1674cd117d",
"content-hash": "d33558ef249a7265942579422b1fbeec",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -780,83 +780,6 @@
],
"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",
@@ -1679,80 +1602,6 @@
},
"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",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/spatie-laravel-tags-plugin.git",
"reference": "7763c2bab92c619cdd9294c69004f89e136c0afc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/spatie-laravel-tags-plugin/zipball/7763c2bab92c619cdd9294c69004f89e136c0afc",
"reference": "7763c2bab92c619cdd9294c69004f89e136c0afc",
"shasum": ""
},
"require": {
"illuminate/database": "^10.45|^11.0|^12.0",
"php": "^8.1",
"spatie/laravel-tags": "^4.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Filament\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Filament support for `spatie/laravel-tags`.",
"homepage": "https://github.com/filamentphp/filament",
"support": {
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2025-05-19T07:27:08+00:00"
},
{
"name": "filament/support",
"version": "v4.0.7",
@@ -3971,84 +3820,6 @@
],
"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",
@@ -6058,208 +5829,6 @@
},
"time": "2021-02-08T20:43:55+00:00"
},
{
"name": "spatie/eloquent-sortable",
"version": "4.5.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/eloquent-sortable.git",
"reference": "c1c4f3a66cd41eb7458783c8a4c8e5d7924a9f20"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/eloquent-sortable/zipball/c1c4f3a66cd41eb7458783c8a4c8e5d7924a9f20",
"reference": "c1c4f3a66cd41eb7458783c8a4c8e5d7924a9f20",
"shasum": ""
},
"require": {
"illuminate/database": "^9.31|^10.0|^11.0|^12.0",
"illuminate/support": "^9.31|^10.0|^11.0|^12.0",
"nesbot/carbon": "^2.63|^3.0",
"php": "^8.1",
"spatie/laravel-package-tools": "^1.9"
},
"require-dev": {
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
"phpunit/phpunit": "^9.5|^10.0|^11.5.3"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\EloquentSortable\\EloquentSortableServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\EloquentSortable\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be"
}
],
"description": "Sortable behaviour for eloquent models",
"homepage": "https://github.com/spatie/eloquent-sortable",
"keywords": [
"behaviour",
"eloquent",
"laravel",
"model",
"sort",
"sortable"
],
"support": {
"issues": "https://github.com/spatie/eloquent-sortable/issues",
"source": "https://github.com/spatie/eloquent-sortable/tree/4.5.2"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"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",
@@ -6319,116 +5888,6 @@
],
"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",
@@ -6490,159 +5949,6 @@
],
"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",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-tags.git",
"reference": "9fc59a9328e892bbb5b01c948b0d703e22d543ec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-tags/zipball/9fc59a9328e892bbb5b01c948b0d703e22d543ec",
"reference": "9fc59a9328e892bbb5b01c948b0d703e22d543ec",
"shasum": ""
},
"require": {
"laravel/framework": "^10.0|^11.0|^12.0",
"nesbot/carbon": "^2.63|^3.0",
"php": "^8.1",
"spatie/eloquent-sortable": "^4.0",
"spatie/laravel-package-tools": "^1.4",
"spatie/laravel-translatable": "^6.0"
},
"require-dev": {
"orchestra/testbench": "^8.0|^9.0|^10.0",
"pestphp/pest": "^1.22|^2.0",
"phpunit/phpunit": "^9.5.2"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Tags\\TagsServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\Tags\\": "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": "Add tags and taggable behaviour to your Laravel app",
"homepage": "https://github.com/spatie/laravel-tags",
"keywords": [
"laravel-tags",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-tags/issues",
"source": "https://github.com/spatie/laravel-tags/tree/4.10.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-03-08T07:49:06+00:00"
},
{
"name": "spatie/laravel-translatable",
"version": "6.11.4",
@@ -6791,140 +6097,6 @@
],
"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",
"source": {
"type": "git",
"url": "https://github.com/stephenjude/filament-blog.git",
"reference": "040414004f876e880889e8d1646de219d85365bc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stephenjude/filament-blog/zipball/040414004f876e880889e8d1646de219d85365bc",
"reference": "040414004f876e880889e8d1646de219d85365bc",
"shasum": ""
},
"require": {
"filament/filament": "^4.0",
"filament/spatie-laravel-tags-plugin": "^v3.0",
"php": "^8.3",
"spatie/laravel-package-tools": "^1.16.1"
},
"require-dev": {
"laravel/pint": "^1.13.1",
"nunomaduro/collision": "^7.8.1|^8.0",
"orchestra/testbench": "^9.0|^10.0",
"pestphp/pest": "^2.18.2|^3.7",
"pestphp/pest-plugin-arch": "^2.3.3|^3.0",
"pestphp/pest-plugin-laravel": "^2.2|^3.1",
"pestphp/pest-plugin-livewire": "^2.1|^3.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Stephenjude\\FilamentBlog\\BlogServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Stephenjude\\FilamentBlog\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "stephenjude",
"email": "stephenjudesuccess@gmail.com",
"role": "Developer"
}
],
"description": "Filament Blog Builder",
"homepage": "https://github.com/stephenjude/filament-blog",
"keywords": [
"blog",
"filament-blog",
"laravel",
"stephenjude"
],
"support": {
"issues": "https://github.com/stephenjude/filament-blog/issues",
"source": "https://github.com/stephenjude/filament-blog/tree/4.2.1"
},
"funding": [
{
"url": "https://github.com/stephenjude",
"type": "github"
}
],
"time": "2025-09-08T07:49:43+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v17.6.0",

View File

@@ -7,9 +7,8 @@ return [
'panels' => [
'superadmin' => [
'resources' => [
\Stephenjude\FilamentBlog\Filament\Resources\PostResource::class,
\Stephenjude\FilamentBlog\Filament\Resources\CategoryResource::class,
\Stephenjude\FilamentBlog\Filament\Resources\TagResource::class,
\App\Filament\Blog\Resources\PostResource::class,
\App\Filament\Blog\Resources\CategoryResource::class,
],
],
],
@@ -19,7 +18,7 @@ return [
* \Filament\Forms\Components\RichEditor::class
* \Filament\Forms\Components\MarkdownEditor::class
*/
'editor' => \Filament\Forms\Components\RichEditor::class,
'editor' => \Filament\Forms\Components\MarkdownEditor::class,
/**
* Configs for Posts banner file that give you option to change

View File

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

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('blog_categories', function (Blueprint $table) {
$table->string('name')->nullable()->after('id');
$table->text('description')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('blog_categories', function (Blueprint $table) {
$table->dropColumn(['name', 'description']);
});
}
};

View File

@@ -11,8 +11,8 @@ return new class extends Migration
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unique('username');
Schema::table('blog_categories', function (Blueprint $table) {
$table->dropColumn(['name', 'description']);
});
}
@@ -21,8 +21,9 @@ return new class extends Migration
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['username']);
Schema::table('blog_categories', function (Blueprint $table) {
$table->string('name')->after('id');
$table->text('description')->nullable()->after('name');
});
}
};
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('blog_categories', function (Blueprint $table) {
if (!Schema::hasColumn('blog_categories', 'name')) {
$table->json('name')->nullable()->after('id');
}
if (!Schema::hasColumn('blog_categories', 'description')) {
$table->json('description')->nullable()->after('name');
}
});
}
public function down(): void
{
Schema::table('blog_categories', function (Blueprint $table) {
$table->dropColumn(['name', 'description']);
});
}
};

View File

@@ -0,0 +1,32 @@
<?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('blog_categories', function (Blueprint $table) {
$table->json('name')->nullable()->change();
$table->json('description')->nullable()->change();
$table->dropColumn('translations');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('blog_categories', function (Blueprint $table) {
$table->string('name')->nullable()->change();
$table->text('description')->nullable()->change();
$table->json('translations')->nullable();
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
return [
'impressum' => 'Impressum',
'datenschutz' => 'Datenschutzerklärung',
'impressum_title' => 'Impressum - Fotospiel',
'datenschutz_title' => 'Datenschutzerklärung - Fotospiel',
'impressum_section' => 'Angaben gemäß § 5 TMG',
'company' => 'Fotospiel GmbH',
'address' => 'Musterstraße 1, 12345 Musterstadt',
'representative' => 'Vertreten durch: Max Mustermann',
'contact' => 'Kontakt',
'vat_id' => 'Umsatzsteuer-ID: DE123456789',
'monetization' => 'Monetarisierung',
'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de',
'register_court' => 'Registergericht: Amtsgericht Musterstadt',
'commercial_register' => 'Handelsregister: HRB 12345',
'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.',
'responsible' => 'Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt',
'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.',
'payments' => 'Zahlungen und Packages',
'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert alle Daten werden verschlüsselt übertragen.',
'data_retention' => '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.',
'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.',
'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.',
'personal_data' => 'Persönliche Datenverarbeitung',
'personal_data_desc' => '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.',
'account_deletion' => 'Account-Löschung',
'account_deletion_desc' => '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.',
'data_security' => 'Datensicherheit',
'data_security_desc' => 'Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).',
'and' => 'und',
'stripe_privacy' => 'Stripe Datenschutz',
'paypal_privacy' => 'PayPal Datenschutz',
];

View File

@@ -20,6 +20,8 @@ return [
'max_events_year' => 'Events/Jahr',
'buy_now' => 'Jetzt kaufen',
'subscribe_now' => 'Jetzt abonnieren',
'register_buy' => 'Registrieren und kaufen',
'register_subscribe' => 'Registrieren und abonnieren',
'faq_title' => 'Häufige Fragen zu Packages',
'faq_q1' => 'Was ist ein Package?',
'faq_a1' => 'Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.',
@@ -36,5 +38,112 @@ return [
'feature_watermark' => 'Wasserzeichen',
'feature_branding' => 'Branding',
'feature_support' => 'Support',
'feature_basic_uploads' => 'Grundlegende Uploads',
'feature_unlimited_sharing' => 'Unbegrenztes Teilen',
'feature_no_watermark' => 'Kein Wasserzeichen',
'feature_custom_tasks' => 'Benutzerdefinierte Tasks',
'feature_advanced_analytics' => 'Erweiterte Analytics',
'feature_priority_support' => 'Priorisierter Support',
'feature_limited_sharing' => 'Begrenztes Teilen',
'feature_no_branding' => 'Kein Branding',
'feature_0' => 'Basis-Feature',
'feature_reseller_dashboard' => 'Reseller-Dashboard',
'feature_custom_branding' => 'Benutzerdefiniertes Branding',
'feature_advanced_reporting' => 'Erweiterte Berichterstattung',
],
'nav' => [
'home' => 'Startseite',
'how_it_works' => 'So funktioniert\'s',
'features' => 'Features',
'occasions' => 'Anlässe',
'occasions_types' => [
'weddings' => 'Hochzeiten',
'birthdays' => 'Geburtstage',
'corporate' => 'Firmenevents',
'family' => 'Familienfeiern',
],
'blog' => 'Blog',
'packages' => 'Packages',
'contact' => 'Kontakt',
'discover_packages' => 'Packages entdecken',
],
'footer' => [
'company' => 'Fotospiel GmbH',
'rights_reserved' => 'Alle Rechte vorbehalten',
],
'blog' => [
'title' => 'Fotospiel - Blog',
'hero_title' => 'Fotospiel Blog',
'hero_description' => 'Tipps, News und Anleitungen zu perfekten Event-Fotos mit QR-Codes, PWA und mehr. Bleib informiert!',
'hero_cta' => 'Mehr über Fotospiel',
'posts_title' => 'Aktuelle Blog-Posts',
'by' => 'Von',
'team' => 'Fotospiel Team',
'published_at' => 'Veröffentlicht am',
'read_more' => 'Lesen',
'back' => 'Zurück zum Blog',
'empty' => 'Noch keine Posts verfügbar. Bleib dran!',
],
'occasions' => [
'title' => 'Fotospiel für :type',
'hero_title' => 'Fotospiel für :type',
'hero_description' => 'Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für :type einfach, mobil und datenschutzkonform.',
'cta' => 'Package wählen',
'weddings' => [
'title' => 'Hochzeiten mit Fotospiel',
'description' => 'Erfange romantische Momente: Gäste teilen Fotos via QR, wähle Emotions wie \'Romantisch\' oder \'Fröhlich\'. Besser als traditionelle Fotoboxen.',
'benefits_title' => 'Vorteile für Hochzeiten',
'benefit1' => 'QR-Code für Gäste: Einfaches Teilen ohne App-Download.',
'benefit2' => 'Emotion-Filter: Kategorisiere Fotos (z.B. \'Tanz\', \'Kuss\').',
'benefit3' => 'Private Galerie: Nur freigegebene Fotos sichtbar.',
'benefit4' => 'Download: Hochauflösend für Album.',
'image_alt' => 'Hochzeitsfotos',
],
'birthdays' => [
'title' => 'Geburtstage feiern',
'description' => 'Lass Freunde und Familie spontane Fotos teilen. QR auf der Torte Spaß garantiert!',
'benefits_title' => 'Vorteile für Geburtstage',
'benefit1' => 'Schnelle Uploads: Kamera oder Galerie.',
'benefit2' => 'Likes & Shares: Beliebte Momente hervorheben.',
'benefit3' => 'Offline-fähig: PWA funktioniert ohne Internet.',
'benefit4' => 'Anonym: Keine Registrierung nötig.',
'image_alt' => 'Geburtstagsfotos',
],
'corporate' => [
'title' => 'Firmenevents professionell',
'description' => 'Netzwerken und Team-Building: Sammle Fotos zentral, teile Highlights intern.',
'benefits_title' => 'Vorteile für Firmenevents',
'benefit1' => 'QR an Ständen: Gäste fotografieren sich selbst.',
'benefit2' => 'Kategorien: \'Team\', \'Netzwerk\', \'Präsentation\'.',
'benefit3' => 'Export: Für Social Media oder Intranet.',
'benefit4' => 'GDPR-sicher: Keine PII gespeichert.',
'image_alt' => 'Firmenevent-Fotos',
],
'family' => [
'title' => 'Familienfeiern',
'description' => 'Von Taufen bis Jubiläen: Sammle Erinnerungen von allen Verwandten.',
'benefits_title' => 'Vorteile für Familienfeiern',
'benefit1' => 'Einfach für alle Altersgruppen: Große Buchstaben, Touch-freundlich.',
'benefit2' => 'Emotionen: \'Familie\', \'Glück\', \'Zusammenhalt\'.',
'benefit3' => 'Teilen: Per Link oder QR für Nachfeier.',
'benefit4' => 'Unbegrenzt: Im Premium-Tarif.',
'image_alt' => 'Familienfotos',
],
'not_found' => 'Anlass nicht gefunden.',
],
'success' => [
'title' => 'Erfolgreich',
'verify_email' => 'E-Mail verifizieren',
'check_email' => 'Überprüfen Sie Ihre E-Mail auf den Verifizierungslink.',
'redirecting' => 'Weiterleitung zum Admin-Bereich...',
'complete_purchase' => 'Kauf abschließen',
'login_to_continue' => 'Melden Sie sich an, um fortzufahren.',
'loading' => 'Laden...',
],
'register' => [
'free' => 'Kostenlos',
],
'currency' => [
'euro' => '€',
],
];

View File

@@ -0,0 +1,32 @@
<?php
return [
'impressum' => 'Imprint',
'datenschutz' => 'Privacy Policy',
'impressum_title' => 'Imprint - Fotospiel',
'datenschutz_title' => 'Privacy Policy - Fotospiel',
'impressum_section' => 'Information pursuant to § 5 TMG',
'company' => 'Fotospiel GmbH',
'address' => 'Musterstraße 1, 12345 Musterstadt',
'representative' => 'Represented by: Max Mustermann',
'contact' => 'Contact',
'vat_id' => 'VAT ID: DE123456789',
'monetization' => 'Monetization',
'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Stripe and PayPal. Prices excl. VAT. Support: support@fotospiel.de',
'register_court' => 'Register Court: District Court Musterstadt',
'commercial_register' => 'Commercial Register: HRB 12345',
'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.',
'responsible' => 'Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt',
'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.',
'payments' => 'Payments and Packages',
'payments_desc' => 'We process payments for Packages via Stripe and PayPal. Card information is not stored all data is transmitted encrypted. See Stripe Privacy and PayPal Privacy.',
'data_retention' => 'Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.',
'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.',
'cookies' => 'Cookies: Only functional cookies for the PWA.',
'personal_data' => 'Personal Data Processing',
'personal_data_desc' => 'During registration and use of the system, the following personal data is processed: First and last name, address, phone number, email address, username. This data is used to fulfill the contract (Package purchase, tenant management) and for authentication. Processing is in accordance with Art. 6 Para. 1 lit. b GDPR.',
'account_deletion' => 'Account Deletion',
'account_deletion_desc' => 'You have the right to have your personal data deleted at any time (right to erasure, Art. 17 GDPR). Contact us at [Email] to delete your account. All associated data (events, photos, purchases) will be deleted, unless legal retention obligations exist.',
'data_security' => 'Data Security',
'data_security_desc' => 'We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).',
];

View File

@@ -0,0 +1,149 @@
<?php
return [
'packages' => [
'title' => 'Our Packages Choose Your Event Package',
'hero_title' => 'Discover our flexible Packages',
'hero_description' => 'From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.',
'cta_explore' => 'Discover Packages',
'tab_endcustomer' => 'End Customers',
'tab_reseller' => 'Resellers & Agencies',
'section_endcustomer' => 'Packages for End Customers (One-time purchase per Event)',
'section_reseller' => 'Packages for Resellers (Annual Subscription)',
'free' => 'Free',
'one_time' => 'One-time purchase',
'subscription' => 'Subscription',
'year' => 'Year',
'max_photos' => 'Photos',
'max_guests' => 'Guests',
'gallery_days' => 'Gallery Days',
'max_events_year' => 'Events/Year',
'buy_now' => 'Buy Now',
'subscribe_now' => 'Subscribe Now',
'register_buy' => 'Register and Buy',
'register_subscribe' => 'Register and Subscribe',
'faq_title' => 'Frequently Asked Questions about Packages',
'faq_q1' => 'What is a Package?',
'faq_a1' => 'A Package defines limits and features for your event, e.g. number of photos and gallery duration.',
'faq_q2' => 'Can I upgrade?',
'faq_a2' => 'Yes, choose a higher package when creating the event or upgrade later.',
'faq_q3' => 'What happens when it expires?',
'faq_a3' => 'The gallery remains readable, but uploads are blocked. Simply extend it.',
'faq_q4' => 'Payment secure?',
'faq_a4' => 'Yes, via Stripe or PayPal secure and GDPR-compliant.',
'final_cta' => 'Ready for your next event?',
'contact_us' => 'Contact Us',
'feature_live_slideshow' => 'Live Slideshow',
'feature_analytics' => 'Analytics',
'feature_watermark' => 'Watermark',
'feature_branding' => 'Branding',
'feature_support' => 'Support',
'feature_basic_uploads' => 'Basic Uploads',
'feature_unlimited_sharing' => 'Unlimited Sharing',
'feature_no_watermark' => 'No Watermark',
'feature_custom_tasks' => 'Custom Tasks',
'feature_advanced_analytics' => 'Advanced Analytics',
'feature_priority_support' => 'Priority Support',
'feature_limited_sharing' => 'Limited Sharing',
'feature_no_branding' => 'No Branding',
'feature_0' => 'Basic Feature',
'feature_reseller_dashboard' => 'Reseller Dashboard',
'feature_custom_branding' => 'Custom Branding',
'feature_advanced_reporting' => 'Advanced Reporting',
],
'nav' => [
'home' => 'Home',
'how_it_works' => 'How it works',
'features' => 'Features',
'occasions' => 'Occasions',
'occasions_types' => [
'weddings' => 'Weddings',
'birthdays' => 'Birthdays',
'corporate' => 'Corporate Events',
'family' => 'Family Celebrations',
],
'blog' => 'Blog',
'packages' => 'Packages',
'contact' => 'Contact',
'discover_packages' => 'Discover Packages',
],
'footer' => [
'company' => 'Fotospiel GmbH',
'rights_reserved' => 'All rights reserved',
],
'blog' => [
'title' => 'Fotospiel - Blog',
'hero_title' => 'Fotospiel Blog',
'hero_description' => 'Tips, News and Guides for perfect Event Photos with QR-Codes, PWA and more. Stay informed!',
'hero_cta' => 'More about Fotospiel',
'posts_title' => 'Current Blog Posts',
'by' => 'By',
'team' => 'Fotospiel Team',
'published_at' => 'Published on',
'read_more' => 'Read',
'back' => 'Back to Blog',
'empty' => 'No posts available yet. Stay tuned!',
],
'occasions' => [
'title' => 'Fotospiel for :type',
'hero_title' => 'Fotospiel for :type',
'hero_description' => 'Collect unforgettable photos from your guests with QR-Codes. Perfect for :type simple, mobile and privacy-compliant.',
'cta' => 'Choose Package',
'weddings' => [
'title' => 'Weddings with Fotospiel',
'description' => 'Capture romantic moments: Guests share photos via QR, choose emotions like \'Romantic\' or \'Joyful\'. Better than traditional photo booths.',
'benefits_title' => 'Benefits for Weddings',
'benefit1' => 'QR-Code for Guests: Easy sharing without app download.',
'benefit2' => 'Emotion Filter: Categorize photos (e.g. \'Dance\', \'Kiss\').',
'benefit3' => 'Private Gallery: Only approved photos visible.',
'benefit4' => 'Download: High-resolution for album.',
'image_alt' => 'Wedding Photos',
],
'birthdays' => [
'title' => 'Celebrate Birthdays',
'description' => 'Let friends and family share spontaneous photos. QR on the cake fun guaranteed!',
'benefits_title' => 'Benefits for Birthdays',
'benefit1' => 'Quick Uploads: Camera or Gallery.',
'benefit2' => 'Likes & Shares: Highlight popular moments.',
'benefit3' => 'Offline-capable: PWA works without internet.',
'benefit4' => 'Anonymous: No registration required.',
'image_alt' => 'Birthday Photos',
],
'corporate' => [
'title' => 'Corporate Events Professionally',
'description' => 'Networking and Team-Building: Collect photos centrally, share highlights internally.',
'benefits_title' => 'Benefits for Corporate Events',
'benefit1' => 'QR at Booths: Guests photograph themselves.',
'benefit2' => 'Categories: \'Team\', \'Network\', \'Presentation\'.',
'benefit3' => 'Export: For Social Media or Intranet.',
'benefit4' => 'GDPR-secure: No PII stored.',
'image_alt' => 'Corporate Event Photos',
],
'family' => [
'title' => 'Family Celebrations',
'description' => 'From baptisms to anniversaries: Collect memories from all relatives.',
'benefits_title' => 'Benefits for Family Celebrations',
'benefit1' => 'Easy for all ages: Large letters, touch-friendly.',
'benefit2' => 'Emotions: \'Family\', \'Happiness\', \'Unity\'.',
'benefit3' => 'Share: Via link or QR for after-party.',
'benefit4' => 'Unlimited: In premium plan.',
'image_alt' => 'Family Photos',
],
'not_found' => 'Occasion not found.',
],
'success' => [
'title' => 'Success',
'verify_email' => 'Verify Email',
'check_email' => 'Check your email for the verification link.',
'redirecting' => 'Redirecting to admin area...',
'complete_purchase' => 'Complete Purchase',
'login_to_continue' => 'Log in to continue.',
'loading' => 'Loading...',
],
'register' => [
'free' => 'Free',
],
'currency' => [
'euro' => '€',
],
];

View File

@@ -6,7 +6,7 @@
<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'])
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>
@keyframes aurora {
0%, 100% { background-position: 0% 50%; }
@@ -18,55 +18,16 @@
animation: aurora 15s ease infinite;
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</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>
@include('partials.header')
<main>
@yield('content')
</main>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-8 px-4">
<div class="container mx-auto text-center">
<p>&copy; 2025 Fotospiel GmbH. Alle Rechte vorbehalten.</p>
<div class="mt-4 space-x-4">
<a href="/impressum" class="hover:text-[#FFB6C1]">Impressum</a>
<a href="/datenschutz" class="hover:text-[#FFB6C1]">Datenschutz</a>
<a href="#contact" class="hover:text-[#FFB6C1]">Kontakt</a>
</div>
</div>
</footer>
@include('partials.footer')
@stack('scripts')
</body>

View File

@@ -1,28 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Datenschutzerklärung - Fotospiel</title>
</head>
<body class="container mx-auto px-4 py-8">
<h1>Datenschutzerklärung</h1>
<p>Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.</p>
<p>Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt</p>
<p>Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.</p>
<h2>Zahlungen und Packages</h2>
<p>Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert alle Daten werden verschlüsselt übertragen. Siehe <a href="https://stripe.com/de/privacy" target="_blank">Stripe Datenschutz</a> und <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank">PayPal Datenschutz</a>.</p>
<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>
@extends('layouts.marketing')
@section('title', __('legal.datenschutz_title'))
@section('content')
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">{{ __('legal.datenschutz') }}</h1>
<p class="mb-4">{{ __('legal.datenschutz_intro') }}</p>
<p class="mb-4">{{ __('legal.responsible') }}</p>
<p class="mb-4">{{ __('legal.data_collection') }}</p>
<h2 class="text-xl font-semibold mb-2">{{ __('legal.payments') }}</h2>
<p class="mb-4">{{ __('legal.payments_desc') }} <a href="https://stripe.com/de/privacy" target="_blank">{{ __('legal.stripe_privacy') }}</a> {{ __('legal.and') }} <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank">{{ __('legal.paypal_privacy') }}</a>.</p>
<p class="mb-4">{{ __('legal.data_retention') }}</p>
<p class="mb-4">{{ __('legal.rights') }} <a href="{{ route('kontakt') }}">{{ __('legal.contact') }}</a>.</p>
<p class="mb-4">{{ __('legal.cookies') }}</p>
<h2 class="text-xl font-semibold mb-2">{{ __('legal.personal_data') }}</h2>
<p class="mb-4">{{ __('legal.personal_data_desc') }}</p>
<h2 class="text-xl font-semibold mb-2">{{ __('legal.account_deletion') }}</h2>
<p class="mb-4">{{ __('legal.account_deletion_desc') }}</p>
<h2 class="text-xl font-semibold mb-2">{{ __('legal.data_security') }}</h2>
<p class="mb-4">{{ __('legal.data_security_desc') }}</p>
</div>
@endsection

View File

@@ -1,23 +1,21 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Impressum - Fotospiel</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
</head>
<body class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">Impressum</h1>
<p class="mb-4">Angaben gemäß § 5 TMG</p>
<p class="mb-4">Fotospiel GmbH<br>
Musterstraße 1<br>
12345 Musterstadt<br>
Vertreten durch: Max Mustermann<br>
Kontakt: <a href="/kontakt">Kontakt</a></p>
<p class="mb-4">Umsatzsteuer-ID: DE123456789</p>
<h2>Monetarisierung</h2>
<p>Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de</p>
<p>Registergericht: Amtsgericht Musterstadt</p>
<p>Handelsregister: HRB 12345</p>
</body>
</html>
@extends('layouts.marketing')
@section('title', __('legal.impressum_title'))
@section('content')
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">{{ __('legal.impressum') }}</h1>
<p class="mb-4">{{ __('legal.impressum_section') }}</p>
<p class="mb-4">
{{ __('legal.company') }}<br>
{{ __('legal.address') }}<br>
{{ __('legal.representative') }}<br>
{{ __('legal.contact') }}: <a href="{{ route('kontakt') }}">{{ __('legal.contact') }}</a>
</p>
<p class="mb-4">{{ __('legal.vat_id') }}</p>
<h2 class="text-xl font-semibold mb-2">{{ __('legal.monetization') }}</h2>
<p class="mb-4">{{ __('legal.monetization_desc') }}</p>
<p class="mb-4">{{ __('legal.register_court') }}</p>
<p class="mb-4">{{ __('legal.commercial_register') }}</p>
</div>
@endsection

View File

@@ -1,48 +1,17 @@
<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
<body class="bg-gray-50 text-gray-900">
<!-- Shared 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="/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="/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">Jetzt starten</a>
</div>
</header>
@extends('layouts.marketing')
@section('title')
{{ $post->getTranslation('title', app()->getLocale()) }} - {{ __('blog.title') }}
@endsection
@section('content')
<!-- 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-lg mb-8">Von {{ $post->author->name ?? 'Fotospiel Team' }} | {{ $post->published_at->format('d.m.Y') }}</p>
<h1 class="text-4xl md:text-5xl font-bold mb-4">{{ $post->getTranslation('title', app()->getLocale()) }}</h1>
<p class="text-lg mb-8">{{ __('blog.by') }} {{ $post->author->name ?? __('blog.team') }} | {{ $post->published_at->format(__('date.format')) }}</p>
@if ($post->featured_image)
<img src="{{ $post->featured_image }}" alt="{{ $post->title }}" class="mx-auto rounded-lg shadow-lg max-w-2xl">
<img src="{{ $post->featured_image }}" alt="{{ $post->getTranslation('title', app()->getLocale()) }}" class="mx-auto rounded-lg shadow-lg max-w-2xl">
@endif
</div>
</section>
@@ -50,27 +19,14 @@
<!-- 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 !!}
{!! $post->getTranslation('content', app()->getLocale()) !!}
</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>
<a href="/blog" class="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition">{{ __('blog.back') }}</a>
</div>
</section>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-8 px-4 mt-20">
<div class="container mx-auto text-center">
<p>&copy; 2025 Fotospiel GmbH. Alle Rechte vorbehalten.</p>
<div class="mt-4 space-x-4">
<a href="/impressum" class="hover:text-[#FFB6C1]">Impressum</a>
<a href="/datenschutz" class="hover:text-[#FFB6C1]">Datenschutz</a>
<a href="/marketing#contact" class="hover:text-[#FFB6C1]">Kontakt</a>
</div>
</div>
</footer>
</body>
</html>
@endsection

View File

@@ -1,32 +1,32 @@
@extends('layouts.marketing')
@section('title', 'Fotospiel - Blog')
@section('title', __('marketing.blog.title'))
@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">
<h1 class="text-4xl md:text-6xl font-bold mb-4">Fotospiel Blog</h1>
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">Tipps, News und Anleitungen zu perfekten Event-Fotos mit QR-Codes, PWA und mehr. Bleib informiert!</p>
<a href="/marketing#how-it-works" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Mehr über Fotospiel</a>
<h1 class="text-4xl md:text-6xl font-bold mb-4">{{ __('marketing.blog.hero_title') }}</h1>
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">{{ __('marketing.blog.hero_description') }}</p>
<a href="/marketing#how-it-works" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">{{ __('marketing.blog.hero_cta') }}</a>
</div>
</section>
<!-- Blog Posts Section -->
<section class="py-20 px-4 bg-white">
<div class="container mx-auto max-w-4xl">
<h2 class="text-3xl font-bold text-center mb-12">Aktuelle Blog-Posts</h2>
<h2 class="text-3xl font-bold text-center mb-12">{{ __('marketing.blog.posts_title') }}</h2>
@if ($posts->count() > 0)
<div class="grid md:grid-cols-2 gap-8">
@foreach ($posts as $post)
<div class="bg-gray-50 p-6 rounded-lg">
@if ($post->featured_image)
<img src="{{ $post->featured_image }}" alt="{{ $post->title }}" class="w-full h-48 object-cover rounded mb-4">
<img src="{{ $post->featured_image }}" alt="{{ $post->getTranslation('title', app()->getLocale()) }}" class="w-full h-48 object-cover rounded mb-4">
@endif
<h3 class="text-xl font-semibold mb-2"><a href="{{ route('blog.show', $post->slug) }}" class="hover:text-[#FFB6C1]">{{ $post->title }}</a></h3>
<p class="mb-4">{{ Str::limit($post->excerpt, 150) }}</p>
<p class="text-sm text-gray-500 mb-4">Veröffentlicht am {{ $post->published_at->format('d.m.Y') }}</p>
<a href="{{ route('blog.show', $post->slug) }}" class="text-[#FFB6C1] font-semibold">Lesen</a>
<h3 class="text-xl font-semibold mb-2"><a href="{{ route('blog.show', $post->slug) }}" class="hover:text-[#FFB6C1]">{{ $post->getTranslation('title', app()->getLocale()) }}</a></h3>
<p class="mb-4">{{ Str::limit($post->getTranslation('excerpt', app()->getLocale()), 150) }}</p>
<p class="text-sm text-gray-500 mb-4">{{ __('marketing.blog.published_at') }} {{ $post->published_at->format(__('date.format')) }}</p>
<a href="{{ route('blog.show', $post->slug) }}" class="text-[#FFB6C1] font-semibold">{{ __('marketing.blog.read_more') }}</a>
</div>
@endforeach
</div>
@@ -36,7 +36,7 @@
</div>
@endif
@else
<p class="text-center text-gray-600">Noch keine Posts verfügbar. Bleib dran!</p>
<p class="text-center text-gray-600">{{ __('marketing.blog.empty') }}</p>
@endif
</div>
</section>

View File

@@ -1,47 +1,22 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fotospiel - {{ ucfirst($type) }} Occasion</title>
<meta name="description" content="Fotospiel für {{ ucfirst($type) }}: Sammle Gastfotos mit QR-Codes.">
<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 (kopiert aus marketing, vereinfacht) -->
<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-600 hover:text-gray-900">Blog</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>
</div>
</header>
@extends('layouts.marketing')
@section('title', __('marketing.occasions.title', ['type' => ucfirst($type)]))
@section('content')
@php
Log::info('Occasions View Debug', [
'type' => $type ?? 'null',
'keyType' => $type ? str_replace('-', '', $type) : 'null',
'locale' => app()->getLocale()
]);
$keyType = str_replace('-', '', $type ?? '');
@endphp
<!-- Hero for Occasion -->
<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-6xl font-bold mb-4">Fotospiel für {{ ucfirst($type) }}</h1>
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für {{ ucfirst($type) }} einfach, mobil und datenschutzkonform.</p>
<a href="/packages" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Package wählen</a>
<h1 class="text-4xl md:text-6xl font-bold mb-4">{{ __('marketing.occasions.hero_title', ['type' => ucfirst($type)]) }}</h1>
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">{{ __('marketing.occasions.hero_description', ['type' => ucfirst($type)]) }}</p>
<a href="{{ route('packages') }}" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">{{ __('marketing.occasions.cta') }}</a>
</div>
</section>
@@ -49,89 +24,76 @@
<section class="py-20 px-4 bg-white">
<div class="container mx-auto max-w-4xl">
@if($type === 'weddings')
<h2 class="text-3xl font-bold text-center mb-12">Hochzeiten mit Fotospiel</h2>
<p class="text-lg mb-8 text-center">Erfange romantische Momente: Gäste teilen Fotos via QR, wähle Emotions wie 'Romantisch' oder 'Fröhlich'. Besser als traditionelle Fotoboxen.</p>
<h2 class="text-3xl font-bold text-center mb-12">{{ __('marketing.occasions.weddings.title') }}</h2>
<p class="text-lg mb-8 text-center">{{ __('marketing.occasions.weddings.description') }}</p>
<div class="grid md:grid-cols-2 gap-8">
<div>
<img src="https://images.unsplash.com/photo-1515934751635-c81c6bc9a2d8?w=600&h=400&fit=crop" alt="Hochzeitsfotos" class="rounded-lg shadow-lg">
<img src="https://images.unsplash.com/photo-1515934751635-c81c6bc9a2d8?w=600&h=400&fit=crop" alt="{{ __('marketing.occasions.weddings.image_alt') }}" class="rounded-lg shadow-lg">
</div>
<div class="space-y-4">
<h3 class="text-2xl font-semibold">Vorteile für Hochzeiten</h3>
<h3 class="text-2xl font-semibold">{{ __('marketing.occasions.weddings.benefits_title') }}</h3>
<ul class="space-y-2">
<li> QR-Code für Gäste: Einfaches Teilen ohne App-Download.</li>
<li> Emotion-Filter: Kategorisiere Fotos (z.B. 'Tanz', 'Kuss').</li>
<li> Private Galerie: Nur freigegebene Fotos sichtbar.</li>
<li> Download: Hochauflösend für Album.</li>
<li> {{ __('marketing.occasions.weddings.benefit1') }}</li>
<li> {{ __('marketing.occasions.weddings.benefit2') }}</li>
<li> {{ __('marketing.occasions.weddings.benefit3') }}</li>
<li> {{ __('marketing.occasions.weddings.benefit4') }}</li>
</ul>
</div>
</div>
@elseif($type === 'birthdays')
<h2 class="text-3xl font-bold text-center mb-12">Geburtstage feiern</h2>
<p class="text-lg mb-8 text-center">Lass Freunde und Familie spontane Fotos teilen. QR auf der Torte Spaß garantiert!</p>
<h2 class="text-3xl font-bold text-center mb-12">{{ __('marketing.occasions.birthdays.title') }}</h2>
<p class="text-lg mb-8 text-center">{{ __('marketing.occasions.birthdays.description') }}</p>
<div class="grid md:grid-cols-2 gap-8">
<div>
<img src="https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=600&h=400&fit=crop" alt="Geburtstagsfotos" class="rounded-lg shadow-lg">
<img src="https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=600&h=400&fit=crop" alt="{{ __('marketing.occasions.birthdays.image_alt') }}" class="rounded-lg shadow-lg">
</div>
<div class="space-y-4">
<h3 class="text-2xl font-semibold">Vorteile für Geburtstage</h3>
<h3 class="text-2xl font-semibold">{{ __('marketing.occasions.birthdays.benefits_title') }}</h3>
<ul class="space-y-2">
<li> Schnelle Uploads: Kamera oder Galerie.</li>
<li> Likes & Shares: Beliebte Momente hervorheben.</li>
<li> Offline-fähig: PWA funktioniert ohne Internet.</li>
<li> Anonym: Keine Registrierung nötig.</li>
<li> {{ __('marketing.occasions.birthdays.benefit1') }}</li>
<li> {{ __('marketing.occasions.birthdays.benefit2') }}</li>
<li> {{ __('marketing.occasions.birthdays.benefit3') }}</li>
<li> {{ __('marketing.occasions.birthdays.benefit4') }}</li>
</ul>
</div>
</div>
@elseif($type === 'corporate-events')
<h2 class="text-3xl font-bold text-center mb-12">Firmenevents professionell</h2>
<p class="text-lg mb-8 text-center">Netzwerken und Team-Building: Sammle Fotos zentral, teile Highlights intern.</p>
<h2 class="text-3xl font-bold text-center mb-12">{{ __('marketing.occasions.corporate.title') }}</h2>
<p class="text-lg mb-8 text-center">{{ __('marketing.occasions.corporate.description') }}</p>
<div class="grid md:grid-cols-2 gap-8">
<div>
<img src="https://images.unsplash.com/photo-1521737604893-d14cc237f11d?w=600&h=400&fit=crop" alt="Firmenevent-Fotos" class="rounded-lg shadow-lg">
<img src="https://images.unsplash.com/photo-1521737604893-d14cc237f11d?w=600&h=400&fit=crop" alt="{{ __('marketing.occasions.corporate.image_alt') }}" class="rounded-lg shadow-lg">
</div>
<div class="space-y-4">
<h3 class="text-2xl font-semibold">Vorteile für Firmenevents</h3>
<h3 class="text-2xl font-semibold">{{ __('marketing.occasions.corporate.benefits_title') }}</h3>
<ul class="space-y-2">
<li> QR an Ständen: Gäste fotografieren sich selbst.</li>
<li> Kategorien: 'Team', 'Netzwerk', 'Präsentation'.</li>
<li> Export: Für Social Media oder Intranet.</li>
<li> GDPR-sicher: Keine PII gespeichert.</li>
<li> {{ __('marketing.occasions.corporate.benefit1') }}</li>
<li> {{ __('marketing.occasions.corporate.benefit2') }}</li>
<li> {{ __('marketing.occasions.corporate.benefit3') }}</li>
<li> {{ __('marketing.occasions.corporate.benefit4') }}</li>
</ul>
</div>
</div>
@elseif($type === 'family-celebrations')
<h2 class="text-3xl font-bold text-center mb-12">Familienfeiern</h2>
<p class="text-lg mb-8 text-center">Von Taufen bis Jubiläen: Sammle Erinnerungen von allen Verwandten.</p>
<h2 class="text-3xl font-bold text-center mb-12">{{ __('marketing.occasions.family.title') }}</h2>
<p class="text-lg mb-8 text-center">{{ __('marketing.occasions.family.description') }}</p>
<div class="grid md:grid-cols-2 gap-8">
<div>
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=600&h=400&fit=crop" alt="Familienfotos" class="rounded-lg shadow-lg">
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=600&h=400&fit=crop" alt="{{ __('marketing.occasions.family.image_alt') }}" class="rounded-lg shadow-lg">
</div>
<div class="space-y-4">
<h3 class="text-2xl font-semibold">Vorteile für Familienfeiern</h3>
<h3 class="text-2xl font-semibold">{{ __('marketing.occasions.family.benefits_title') }}</h3>
<ul class="space-y-2">
<li> Einfach für alle Altersgruppen: Große Buchstaben, Touch-freundlich.</li>
<li> Emotionen: 'Familie', 'Glück', 'Zusammenhalt'.</li>
<li> Teilen: Per Link oder QR für Nachfeier.</li>
<li> Unbegrenzt: Im Premium-Tarif.</li>
<li> {{ __('marketing.occasions.family.benefit1') }}</li>
<li> {{ __('marketing.occasions.family.benefit2') }}</li>
<li> {{ __('marketing.occasions.family.benefit3') }}</li>
<li> {{ __('marketing.occasions.family.benefit4') }}</li>
</ul>
</div>
</div>
@else
<p class="text-center">Occasion nicht gefunden. <a href="/marketing">Zurück zur Startseite</a>.</p>
<p class="text-center">{{ __('marketing.occasions.not_found') }} <a href="{{ route('marketing') }}">{{ __('nav.home') }}</a>.</p>
@endif
</div>
</section>
<!-- Footer (kopiert aus marketing) -->
<footer class="bg-gray-800 text-white py-8 px-4 mt-20">
<div class="container mx-auto text-center">
<p>&copy; 2025 Fotospiel GmbH. Alle Rechte vorbehalten.</p>
<div class="mt-4 space-x-4">
<a href="/impressum" class="hover:text-[#FFB6C1]">Impressum</a>
<a href="/datenschutz" class="hover:text-[#FFB6C1]">Datenschutz</a>
<a href="/marketing#contact" class="hover:text-[#FFB6C1]">Kontakt</a>
</div>
</div>
</footer>
</body>
</html>
@endsection

View File

@@ -38,7 +38,7 @@
{{ __('marketing.packages.section_endcustomer') }}
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
@foreach(\App\Models\Package::where('type', 'endcustomer')->orderBy('price')->get() as $package)
@foreach($endcustomerPackages as $package)
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200 hover:shadow-lg transition duration-300">
<div class="text-center mb-4">
<h3 class="text-xl font-semibold text-gray-900">{{ $package->name }}</h3>
@@ -103,7 +103,7 @@
{{ __('marketing.packages.section_reseller') }}
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
@foreach(\App\Models\Package::where('type', 'reseller')->orderBy('price')->get() as $package)
@foreach($resellerPackages as $package)
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200 hover:shadow-lg transition duration-300">
<div class="text-center mb-4">
<h3 class="text-xl font-semibold text-gray-900">{{ $package->name }}</h3>
@@ -183,6 +183,8 @@
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {

View File

@@ -14,7 +14,7 @@
<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 . ' ' }}
{{ $package->price == 0 ? __('marketing.register.free') : $package->price . ' ' . __('currency.euro') }}
</p>
</div>
@endif

View File

@@ -1,4 +1,4 @@
@extends('marketing.layout')
@extends('layouts.marketing')
@section('title', __('marketing.success.title'))
@@ -11,7 +11,7 @@
</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>
<span class="sr-only">{{ __('marketing.success.loading') }}</span>
</div>
<p class="mt-2 text-gray-600">{{ __('marketing.success.redirecting') }}</p>
</div>

View File

@@ -0,0 +1,10 @@
<footer class="bg-gray-800 text-white py-8 px-4">
<div class="container mx-auto text-center">
<p>&copy; 2025 {{ __('marketing.footer.company') }}. {{ __('marketing.footer.rights_reserved') }}</p>
<div class="mt-4 space-x-4">
<a href="{{ route('impressum') }}" class="hover:text-[#FFB6C1]">{{ __('legal.impressum') }}</a>
<a href="{{ route('datenschutz') }}" class="hover:text-[#FFB6C1]">{{ __('legal.datenschutz') }}</a>
<a href="{{ route('kontakt') }}" class="hover:text-[#FFB6C1]">{{ __('marketing.nav.contact') }}</a>
</div>
</div>
</footer>

View File

@@ -0,0 +1,30 @@
<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">Die Fotospiel.App</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">{{ __('marketing.nav.how_it_works') }}</a>
<a href="/#features" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.features') }}</a>
<div x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false" @click.away="open = false" class="relative">
<button class="text-gray-600 hover:text-gray-900" @click.stop="open = !open">{{ __('marketing.nav.occasions') }}</button>
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg z-10">
<a href="{{ route('occasions.type', ['locale' => app()->getLocale(), 'type' => 'weddings']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.weddings') }}</a>
<a href="{{ route('occasions.type', ['locale' => app()->getLocale(), 'type' => 'birthdays']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.birthdays') }}</a>
<a href="{{ route('occasions.type', ['locale' => app()->getLocale(), 'type' => 'corporate-events']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.corporate') }}</a>
<a href="{{ route('occasions.type', ['locale' => app()->getLocale(), 'type' => 'family-celebrations']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.family') }}</a>
</div>
</div>
<a href="{{ route('blog') }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.blog') }}</a>
<a href="{{ route('packages') }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.packages') }}</a>
<a href="{{ route('kontakt') }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.contact') }}</a>
<a href="{{ route('packages') }}" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">{{ __('marketing.nav.discover_packages') }}</a>
</nav>
<!-- Mobile Menu Placeholder (Hamburger) -->
<button class="md:hidden text-gray-600"></button>
</div>
</header>

View File

@@ -2,35 +2,56 @@
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Illuminate\Support\Facades\Log;
// Marketing-Seite mit Locale-Prefix
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
Route::view('/', 'marketing')->name('marketing');
Route::get('/occasions/{type}', function ($type) {
return view('marketing.occasions', ['type' => $type]);
})->name('occasions.type');
Route::get('/packages', [\App\Http\Controllers\MarketingController::class, 'packagesIndex'])->name('packages');
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');
// Fallback for /packages (redirect to default locale)
Route::get('/packages', function () {
return redirect('/de/packages');
})->name('packages.fallback');
// 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');
// Fallback for /blog (redirect to default locale)
Route::get('/blog', function () {
return redirect('/de/blog');
})->name('blog.fallback');
// Legal Pages
// Blog Routes (inside locale group for i18n support)
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
Route::get('/blog', [\App\Http\Controllers\MarketingController::class, 'blogIndex'])->name('blog');
Route::get('/blog/{post}', [\App\Http\Controllers\MarketingController::class, 'blogShow'])->name('blog.show');
});
// Fallbacks for Legal Pages (redirect to default locale)
Route::get('/impressum', function () {
return view('legal.impressum');
})->name('impressum');
return redirect('/de/impressum');
})->name('impressum.fallback');
Route::get('/datenschutz', function () {
return view('legal.datenschutz');
})->name('datenschutz');
return redirect('/de/datenschutz');
})->name('datenschutz.fallback');
Route::get('/kontakt', function () {
return view('legal.kontakt');
})->name('kontakt');
Route::post('/kontakt', [\App\Http\Controllers\MarketingController::class, 'contact'])->name('kontakt.submit');
return redirect('/de/kontakt');
})->name('kontakt.fallback');
// Legal Pages in locale group
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
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 () {
@@ -95,7 +116,11 @@ Route::middleware('auth')->group(function () {
Route::patch('/profile', [\App\Http\Controllers\ProfileController::class, 'update'])->name('profile.update');
});
// 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');
Route::get('/marketing/success/{package_id?}', [\App\Http\Controllers\MarketingController::class, 'success'])->name('marketing.success');
Route::get('{locale}/occasions/{type}', [\App\Http\Controllers\MarketingController::class, 'occasionsType'])
->where([
'locale' => 'de|en',
'type' => 'weddings|birthdays|corporate-events|family-celebrations'
])
->name('occasions.type');

View File

@@ -10,6 +10,9 @@ use App\Models\PackagePurchase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Illuminate\Support\Facades\Auth;
use Mockery;
use PayPal\PayPalHttp\Client;
use PayPal\Checkout\Orders\Order;
class PurchaseTest extends TestCase
{
@@ -71,4 +74,67 @@ class PurchaseTest extends TestCase
$response->assertStatus(302); // Redirect to Stripe
$this->assertStringContainsString('checkout.stripe.com', $response->headers->get('Location'));
}
public function test_paypal_checkout_creates_order()
{
$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);
$mockClient = Mockery::mock(\PayPal\PayPalHttp\Client::class);
$mockOrders = Mockery::mock();
$mockOrders->shouldReceive('createOrder')->andReturn(new \stdClass()); // Simplified mock
$mockClient->shouldReceive('orders')->andReturn($mockOrders);
$this->app->instance(\PayPal\PayPalHttp\Client::class, $mockClient);
$response = $this->get(route('buy.packages', $paidPackage->id) . '?provider=paypal');
$response->assertStatus(302);
$this->assertNotNull(session('paypal_order_id'));
}
public function test_paypal_success_captures_and_activates_package()
{
$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);
$metadata = json_encode([
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $paidPackage->id,
'type' => $paidPackage->type,
]);
$mockClient = Mockery::mock(\PayPal\PayPalHttp\Client::class);
$mockOrders = Mockery::mock();
$mockCapture = new \stdClass();
$mockCapture->status = 'COMPLETED';
$mockCapture->purchaseUnits = [(object)['custom_id' => $metadata]];
$mockResponse = new \stdClass();
$mockResponse->result = $mockCapture;
$mockOrders->shouldReceive('captureOrder')->andReturn($mockResponse);
$mockClient->shouldReceive('orders')->andReturn($mockOrders);
$this->app->instance(\PayPal\PayPalHttp\Client::class, $mockClient);
session(['paypal_order_id' => 'test-order-id']);
$response = $this->get(route('marketing.success', $paidPackage->id));
$response->assertRedirect('/admin');
$this->assertDatabaseHas('tenant_packages', [
'tenant_id' => $tenant->id,
'package_id' => $paidPackage->id,
'active' => true,
]);
$this->assertDatabaseHas('package_purchases', [
'tenant_id' => $tenant->id,
'package_id' => $paidPackage->id,
'provider_id' => 'paypal',
]);
$this->assertNull(session('paypal_order_id'));
$this->assertEquals('active', $tenant->fresh()->subscription_status);
}
}