- Wired the checkout wizard for Google “comfort login”: added Socialite controller + dependency, new Google env
hooks in config/services.php/.env.example, and updated wizard steps/controllers to store session payloads, attach packages, and surface localized success/error states. - Retooled payment handling for both Stripe and PayPal, adding richer status management in CheckoutController/ PayPalController, fallback flows in the wizard’s PaymentStep.tsx, and fresh feature tests for intent creation, webhooks, and the wizard CTA. - Introduced a consent-aware Matomo analytics stack: new consent context, cookie-banner UI, useAnalytics/ useCtaExperiment hooks, and MatomoTracker component, then instrumented marketing pages (Home, Packages, Checkout) with localized copy and experiment tracking. - Polished package presentation across marketing UIs by centralizing formatting in PresentsPackages, surfacing localized description tables/placeholders, tuning badges/layouts, and syncing guest/marketing translations. - Expanded docs & reference material (docs/prp/*, TODOs, public gallery overview) and added a Playwright smoke test for the hero CTA while reconciling outstanding checklist items.
This commit is contained in:
14
.env.example
14
.env.example
@@ -69,9 +69,23 @@ STRIPE_WEBHOOK_SECRET=
|
|||||||
STRIPE_CONNECT_CLIENT_ID=
|
STRIPE_CONNECT_CLIENT_ID=
|
||||||
STRIPE_CONNECT_SECRET=
|
STRIPE_CONNECT_SECRET=
|
||||||
|
|
||||||
|
# Google OAuth (Checkout comfort login)
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
REVENUECAT_WEBHOOK_SECRET=
|
REVENUECAT_WEBHOOK_SECRET=
|
||||||
REVENUECAT_PRODUCT_MAPPINGS=
|
REVENUECAT_PRODUCT_MAPPINGS=
|
||||||
REVENUECAT_APP_USER_PREFIX=tenant
|
REVENUECAT_APP_USER_PREFIX=tenant
|
||||||
|
REVENUECAT_WEBHOOK_QUEUE=webhooks
|
||||||
|
|
||||||
|
CHECKOUT_WIZARD_ENABLED=true
|
||||||
|
CHECKOUT_WIZARD_FLAG=checkout-wizard-2025
|
||||||
|
|
||||||
|
OAUTH_JWT_KID=fotospiel-jwt
|
||||||
|
OAUTH_KEY_STORE=
|
||||||
|
OAUTH_REFRESH_ENFORCE_IP=true
|
||||||
|
OAUTH_REFRESH_ALLOW_SUBNET=false
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
111
app/Console/Commands/OAuthRotateKeysCommand.php
Normal file
111
app/Console/Commands/OAuthRotateKeysCommand.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class OAuthRotateKeysCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'oauth:rotate-keys {--kid=} {--force : Do not prompt for confirmation}';
|
||||||
|
|
||||||
|
protected $description = 'Generate a new JWT signing key pair for tenant OAuth tokens.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||||
|
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
|
||||||
|
$newKid = $this->option('kid') ?: 'kid-'.now()->format('YmdHis');
|
||||||
|
|
||||||
|
if (! $this->option('force') &&
|
||||||
|
! $this->confirm("Rotate JWT keys? Current kid: {$currentKid}. New kid: {$newKid}", true)
|
||||||
|
) {
|
||||||
|
$this->info('Rotation cancelled.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
File::ensureDirectoryExists($storage);
|
||||||
|
|
||||||
|
$archiveDir = $this->archiveExistingKeys($storage, $currentKid);
|
||||||
|
|
||||||
|
$newDirectory = $storage.DIRECTORY_SEPARATOR.$newKid;
|
||||||
|
if (File::exists($newDirectory)) {
|
||||||
|
$this->error("Target directory already exists: {$newDirectory}");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
File::makeDirectory($newDirectory, 0700, true);
|
||||||
|
$this->generateKeyPair($newDirectory);
|
||||||
|
|
||||||
|
$this->info('New signing keys generated.');
|
||||||
|
$this->line("Path: {$newDirectory}");
|
||||||
|
|
||||||
|
if ($archiveDir) {
|
||||||
|
$this->line("Previous keys archived at: {$archiveDir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function archiveExistingKeys(string $storage, string $kid): ?string
|
||||||
|
{
|
||||||
|
$existingDir = $storage.DIRECTORY_SEPARATOR.$kid;
|
||||||
|
$legacyPublic = storage_path('app/public.key');
|
||||||
|
$legacyPrivate = storage_path('app/private.key');
|
||||||
|
|
||||||
|
if (File::exists($existingDir)) {
|
||||||
|
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis');
|
||||||
|
File::ensureDirectoryExists(dirname($archiveDir));
|
||||||
|
File::moveDirectory($existingDir, $archiveDir);
|
||||||
|
return $archiveDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($legacyPublic) || File::exists($legacyPrivate)) {
|
||||||
|
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.'legacy-'.now()->format('YmdHis');
|
||||||
|
File::ensureDirectoryExists($archiveDir);
|
||||||
|
|
||||||
|
if (File::exists($legacyPublic)) {
|
||||||
|
File::move($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($legacyPrivate)) {
|
||||||
|
File::move($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $archiveDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateKeyPair(string $directory): void
|
||||||
|
{
|
||||||
|
$config = [
|
||||||
|
'digest_alg' => OPENSSL_ALGO_SHA256,
|
||||||
|
'private_key_bits' => 4096,
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||||
|
];
|
||||||
|
|
||||||
|
$resource = openssl_pkey_new($config);
|
||||||
|
if (! $resource) {
|
||||||
|
throw new \RuntimeException('Failed to generate key pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
openssl_pkey_export($resource, $privateKey);
|
||||||
|
$details = openssl_pkey_get_details($resource);
|
||||||
|
$publicKey = $details['key'] ?? null;
|
||||||
|
|
||||||
|
if (! $publicKey) {
|
||||||
|
throw new \RuntimeException('Unable to extract public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey);
|
||||||
|
File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600);
|
||||||
|
|
||||||
|
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey);
|
||||||
|
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
37
app/Exports/PurchaseHistoryExporter.php
Normal file
37
app/Exports/PurchaseHistoryExporter.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\PurchaseHistory;
|
||||||
|
use Filament\Actions\Exports\ExportColumn;
|
||||||
|
use Filament\Actions\Exports\Exporter;
|
||||||
|
use Filament\Actions\Exports\Models\Export;
|
||||||
|
|
||||||
|
class PurchaseHistoryExporter extends Exporter
|
||||||
|
{
|
||||||
|
public static function getModel(): string
|
||||||
|
{
|
||||||
|
return PurchaseHistory::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getColumns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ExportColumn::make('tenant.name')->label(__('admin.purchase_history.fields.tenant')),
|
||||||
|
ExportColumn::make('package_id')->label(__('admin.purchase_history.fields.package')),
|
||||||
|
ExportColumn::make('credits_added')->label(__('admin.purchase_history.fields.credits')),
|
||||||
|
ExportColumn::make('price')->label(__('admin.purchase_history.fields.price')),
|
||||||
|
ExportColumn::make('currency')->label(__('admin.purchase_history.fields.currency')),
|
||||||
|
ExportColumn::make('platform')->label(__('admin.purchase_history.fields.platform')),
|
||||||
|
ExportColumn::make('transaction_id')->label(__('admin.purchase_history.fields.transaction_id')),
|
||||||
|
ExportColumn::make('purchased_at')->label(__('admin.purchase_history.fields.purchased_at')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getCompletedNotificationBody(Export $export): string
|
||||||
|
{
|
||||||
|
return __('admin.purchase_history.export_success', [
|
||||||
|
'count' => $export->successful_rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
190
app/Filament/Resources/OAuthClientResource.php
Normal file
190
app/Filament/Resources/OAuthClientResource.php
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OAuthClientResource\Pages;
|
||||||
|
use App\Models\OAuthClient;
|
||||||
|
use App\Models\RefreshToken;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OAuthClientResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = OAuthClient::class;
|
||||||
|
|
||||||
|
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-key';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 30;
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): string
|
||||||
|
{
|
||||||
|
return __('admin.nav.security');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $form): Schema
|
||||||
|
{
|
||||||
|
return $form->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->label(__('admin.oauth.fields.name'))
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('client_id')
|
||||||
|
->label(__('admin.oauth.fields.client_id'))
|
||||||
|
->required()
|
||||||
|
->unique(ignoreRecord: true)
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('client_secret')
|
||||||
|
->label(__('admin.oauth.fields.client_secret'))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->helperText(__('admin.oauth.hints.client_secret'))
|
||||||
|
->dehydrated(fn (?string $state): bool => filled($state))
|
||||||
|
->dehydrateStateUsing(fn (?string $state): ?string => filled($state) ? Hash::make($state) : null),
|
||||||
|
Forms\Components\Select::make('tenant_id')
|
||||||
|
->label(__('admin.oauth.fields.tenant'))
|
||||||
|
->relationship('tenant', 'name')
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->nullable(),
|
||||||
|
Forms\Components\Textarea::make('redirect_uris')
|
||||||
|
->label(__('admin.oauth.fields.redirect_uris'))
|
||||||
|
->rows(4)
|
||||||
|
->helperText(__('admin.oauth.hints.redirect_uris'))
|
||||||
|
->formatStateUsing(fn ($state): string => is_array($state) ? implode(PHP_EOL, $state) : (string) $state)
|
||||||
|
->dehydrateStateUsing(function (?string $state): array {
|
||||||
|
$entries = collect(preg_split('/\r\n|\r|\n/', (string) $state))
|
||||||
|
->map(fn ($uri) => trim($uri))
|
||||||
|
->filter();
|
||||||
|
|
||||||
|
return $entries->values()->all();
|
||||||
|
})
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TagsInput::make('scopes')
|
||||||
|
->label(__('admin.oauth.fields.scopes'))
|
||||||
|
->placeholder('tenant:read')
|
||||||
|
->suggestions([
|
||||||
|
'tenant:read',
|
||||||
|
'tenant:write',
|
||||||
|
'tenant:admin',
|
||||||
|
])
|
||||||
|
->separator(',')
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Toggle::make('is_active')
|
||||||
|
->label(__('admin.oauth.fields.is_active'))
|
||||||
|
->default(true),
|
||||||
|
Forms\Components\Textarea::make('description')
|
||||||
|
->label(__('admin.oauth.fields.description'))
|
||||||
|
->rows(3)
|
||||||
|
->columnSpanFull(),
|
||||||
|
])->columns(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label(__('admin.oauth.fields.name'))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('client_id')
|
||||||
|
->label(__('admin.oauth.fields.client_id'))
|
||||||
|
->copyable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
|
->label(__('admin.oauth.fields.tenant'))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\IconColumn::make('is_active')
|
||||||
|
->label(__('admin.oauth.fields.is_active'))
|
||||||
|
->boolean()
|
||||||
|
->color(fn (bool $state): string => $state ? 'success' : 'danger'),
|
||||||
|
Tables\Columns\TextColumn::make('redirect_uris')
|
||||||
|
->label(__('admin.oauth.fields.redirect_uris'))
|
||||||
|
->formatStateUsing(fn ($state) => collect(Arr::wrap($state))->implode("\n"))
|
||||||
|
->limit(50)
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TagsColumn::make('scopes')
|
||||||
|
->label(__('admin.oauth.fields.scopes'))
|
||||||
|
->separator(', ')
|
||||||
|
->limit(4),
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label(__('admin.oauth.fields.updated_at'))
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\TernaryFilter::make('is_active')
|
||||||
|
->label(__('admin.oauth.filters.is_active'))
|
||||||
|
->placeholder(__('admin.oauth.filters.any'))
|
||||||
|
->trueLabel(__('admin.oauth.filters.active'))
|
||||||
|
->falseLabel(__('admin.oauth.filters.inactive')),
|
||||||
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
|
->label(__('admin.oauth.fields.tenant'))
|
||||||
|
->relationship('tenant', 'name')
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\ViewAction::make(),
|
||||||
|
Tables\Actions\EditAction::make(),
|
||||||
|
Tables\Actions\Action::make('regenerate_secret')
|
||||||
|
->label(__('admin.oauth.actions.regenerate_secret'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (OAuthClient $record): void {
|
||||||
|
$plainSecret = Str::random(48);
|
||||||
|
|
||||||
|
$record->forceFill([
|
||||||
|
'client_secret' => Hash::make($plainSecret),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('admin.oauth.notifications.secret_regenerated_title'))
|
||||||
|
->body(__('admin.oauth.notifications.secret_regenerated_body', [
|
||||||
|
'secret' => $plainSecret,
|
||||||
|
]))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Tables\Actions\DeleteAction::make()
|
||||||
|
->before(function (OAuthClient $record): void {
|
||||||
|
RefreshToken::query()
|
||||||
|
->where('client_id', $record->client_id)
|
||||||
|
->delete();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\DeleteBulkAction::make()
|
||||||
|
->before(function (Collection $records): void {
|
||||||
|
$records->each(function (OAuthClient $record) {
|
||||||
|
RefreshToken::query()
|
||||||
|
->where('client_id', $record->client_id)
|
||||||
|
->delete();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListOAuthClients::route('/'),
|
||||||
|
'create' => Pages\CreateOAuthClient::route('/create'),
|
||||||
|
'view' => Pages\ViewOAuthClient::route('/{record}'),
|
||||||
|
'edit' => Pages\EditOAuthClient::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OAuthClientResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OAuthClientResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateOAuthClient extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OAuthClientResource::class;
|
||||||
|
|
||||||
|
protected function getCreatedNotificationTitle(): ?string
|
||||||
|
{
|
||||||
|
return __('admin.oauth.notifications.created_title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OAuthClientResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OAuthClientResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditOAuthClient extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OAuthClientResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSavedNotificationTitle(): ?string
|
||||||
|
{
|
||||||
|
return __('admin.oauth.notifications.updated_title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OAuthClientResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OAuthClientResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListOAuthClients extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = OAuthClientResource::class;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OAuthClientResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OAuthClientResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewOAuthClient extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OAuthClientResource::class;
|
||||||
|
}
|
||||||
|
|
||||||
189
app/Filament/Resources/PurchaseHistoryResource.php
Normal file
189
app/Filament/Resources/PurchaseHistoryResource.php
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Exports\PurchaseHistoryExporter;
|
||||||
|
use App\Filament\Resources\PurchaseHistoryResource\Pages;
|
||||||
|
use App\Models\PurchaseHistory;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\ExportBulkAction;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PurchaseHistoryResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = PurchaseHistory::class;
|
||||||
|
|
||||||
|
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-receipt-refund';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): string
|
||||||
|
{
|
||||||
|
return __('admin.nav.billing');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $form): Schema
|
||||||
|
{
|
||||||
|
return $form->schema([
|
||||||
|
Forms\Components\Select::make('tenant_id')
|
||||||
|
->label(__('admin.purchase_history.fields.tenant'))
|
||||||
|
->relationship('tenant', 'name')
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('package_id')
|
||||||
|
->label(__('admin.purchase_history.fields.package'))
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('credits_added')
|
||||||
|
->label(__('admin.purchase_history.fields.credits'))
|
||||||
|
->numeric()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('price')
|
||||||
|
->label(__('admin.purchase_history.fields.price'))
|
||||||
|
->numeric()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('currency')
|
||||||
|
->label(__('admin.purchase_history.fields.currency'))
|
||||||
|
->maxLength(3)
|
||||||
|
->default('EUR'),
|
||||||
|
Forms\Components\TextInput::make('platform')
|
||||||
|
->label(__('admin.purchase_history.fields.platform'))
|
||||||
|
->maxLength(50)
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('transaction_id')
|
||||||
|
->label(__('admin.purchase_history.fields.transaction_id'))
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\DateTimePicker::make('purchased_at')
|
||||||
|
->label(__('admin.purchase_history.fields.purchased_at'))
|
||||||
|
->required(),
|
||||||
|
])->columns(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
|
->label(__('admin.purchase_history.fields.tenant'))
|
||||||
|
->sortable()
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('package_id')
|
||||||
|
->label(__('admin.purchase_history.fields.package'))
|
||||||
|
->badge()
|
||||||
|
->sortable()
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('credits_added')
|
||||||
|
->label(__('admin.purchase_history.fields.credits'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (int $state): string => $state > 0 ? 'success' : ($state < 0 ? 'danger' : 'gray'))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('price')
|
||||||
|
->label(__('admin.purchase_history.fields.price'))
|
||||||
|
->formatStateUsing(fn ($state, PurchaseHistory $record): string => number_format((float) $state, 2).' '.($record->currency ?? 'EUR'))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('platform')
|
||||||
|
->label(__('admin.purchase_history.fields.platform'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(function ($state): string {
|
||||||
|
$key = 'admin.purchase_history.platforms.' . (string) $state;
|
||||||
|
$translated = __($key);
|
||||||
|
|
||||||
|
return $translated === $key ? Str::headline((string) $state) : $translated;
|
||||||
|
})
|
||||||
|
->sortable()
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('transaction_id')
|
||||||
|
->label(__('admin.purchase_history.fields.transaction_id'))
|
||||||
|
->copyable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('purchased_at')
|
||||||
|
->label(__('admin.purchase_history.fields.purchased_at'))
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\Filter::make('purchased_at')
|
||||||
|
->label(__('admin.purchase_history.filters.purchased_at'))
|
||||||
|
->form([
|
||||||
|
Forms\Components\DatePicker::make('from')->label(__('admin.common.from')),
|
||||||
|
Forms\Components\DatePicker::make('until')->label(__('admin.common.until')),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
return $query
|
||||||
|
->when(
|
||||||
|
$data['from'] ?? null,
|
||||||
|
fn (Builder $builder, $date): Builder => $builder->whereDate('purchased_at', '>=', $date),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$data['until'] ?? null,
|
||||||
|
fn (Builder $builder, $date): Builder => $builder->whereDate('purchased_at', '<=', $date),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Tables\Filters\SelectFilter::make('platform')
|
||||||
|
->label(__('admin.purchase_history.filters.platform'))
|
||||||
|
->options([
|
||||||
|
'ios' => __('admin.purchase_history.platforms.ios'),
|
||||||
|
'android' => __('admin.purchase_history.platforms.android'),
|
||||||
|
'web' => __('admin.purchase_history.platforms.web'),
|
||||||
|
'manual' => __('admin.purchase_history.platforms.manual'),
|
||||||
|
]),
|
||||||
|
Tables\Filters\SelectFilter::make('currency')
|
||||||
|
->label(__('admin.purchase_history.filters.currency'))
|
||||||
|
->options([
|
||||||
|
'EUR' => 'EUR',
|
||||||
|
'USD' => 'USD',
|
||||||
|
]),
|
||||||
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
|
->label(__('admin.purchase_history.filters.tenant'))
|
||||||
|
->relationship('tenant', 'name')
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
ExportBulkAction::make()
|
||||||
|
->label(__('admin.purchase_history.actions.export'))
|
||||||
|
->exporter(PurchaseHistoryExporter::class),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListPurchaseHistories::route('/'),
|
||||||
|
'view' => Pages\ViewPurchaseHistory::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit($record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete($record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDeleteAny(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PurchaseHistoryResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PurchaseHistoryResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPurchaseHistories extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PurchaseHistoryResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PurchaseHistoryResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PurchaseHistoryResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewPurchaseHistory extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PurchaseHistoryResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ use Filament\Tables\Columns\IconColumn;
|
|||||||
use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager;
|
use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager;
|
||||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
||||||
use Filament\Resources\RelationManagers\RelationGroup;
|
use Filament\Resources\RelationManagers\RelationGroup;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -56,6 +57,10 @@ class TenantResource extends Resource
|
|||||||
->email()
|
->email()
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
TextInput::make('event_credits_balance')
|
||||||
|
->label(__('admin.tenants.fields.event_credits_balance'))
|
||||||
|
->numeric()
|
||||||
|
->readOnly(),
|
||||||
TextInput::make('total_revenue')
|
TextInput::make('total_revenue')
|
||||||
->label(__('admin.tenants.fields.total_revenue'))
|
->label(__('admin.tenants.fields.total_revenue'))
|
||||||
->prefix('€')
|
->prefix('€')
|
||||||
@@ -99,6 +104,10 @@ class TenantResource extends Resource
|
|||||||
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
||||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('contact_email'),
|
Tables\Columns\TextColumn::make('contact_email'),
|
||||||
|
Tables\Columns\TextColumn::make('event_credits_balance')
|
||||||
|
->label(__('admin.tenants.fields.event_credits_balance'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (int $state): string => $state <= 0 ? 'danger' : ($state < 5 ? 'warning' : 'success')),
|
||||||
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
||||||
->label(__('admin.tenants.fields.active_package'))
|
->label(__('admin.tenants.fields.active_package'))
|
||||||
->badge()
|
->badge()
|
||||||
@@ -159,10 +168,49 @@ class TenantResource extends Resource
|
|||||||
'metadata' => ['reason' => $data['reason'] ?? 'manual assignment'],
|
'metadata' => ['reason' => $data['reason'] ?? 'manual assignment'],
|
||||||
]);
|
]);
|
||||||
}),
|
}),
|
||||||
|
Actions\Action::make('adjust_credits')
|
||||||
|
->label(__('admin.tenants.actions.adjust_credits'))
|
||||||
|
->icon('heroicon-o-banknotes')
|
||||||
|
->authorize(fn (Tenant $record): bool => auth()->user()?->can('adjustCredits', $record) ?? false)
|
||||||
|
->form([
|
||||||
|
Forms\Components\TextInput::make('delta')
|
||||||
|
->label(__('admin.tenants.actions.adjust_credits_delta'))
|
||||||
|
->numeric()
|
||||||
|
->required()
|
||||||
|
->rule('integer')
|
||||||
|
->helperText(__('admin.tenants.actions.adjust_credits_delta_hint')),
|
||||||
|
Forms\Components\Textarea::make('reason')
|
||||||
|
->label(__('admin.tenants.actions.adjust_credits_reason'))
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(500),
|
||||||
|
])
|
||||||
|
->action(function (Tenant $record, array $data): void {
|
||||||
|
$delta = (int) ($data['delta'] ?? 0);
|
||||||
|
|
||||||
|
if ($delta === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newBalance = max(0, $record->event_credits_balance + $delta);
|
||||||
|
|
||||||
|
$record->forceFill([
|
||||||
|
'event_credits_balance' => $newBalance,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('admin.tenants.actions.adjust_credits_success_title'))
|
||||||
|
->body(__('admin.tenants.actions.adjust_credits_success_body', [
|
||||||
|
'delta' => $delta,
|
||||||
|
'balance' => $newBalance,
|
||||||
|
]))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\Action::make('suspend')
|
Actions\Action::make('suspend')
|
||||||
->label('Suspendieren')
|
->label('Suspendieren')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->authorize(fn (Tenant $record): bool => auth()->user()?->can('suspend', $record) ?? false)
|
||||||
->action(fn (Tenant $record) => $record->update(['is_suspended' => true])),
|
->action(fn (Tenant $record) => $record->update(['is_suspended' => true])),
|
||||||
Actions\Action::make('export')
|
Actions\Action::make('export')
|
||||||
->label('Daten exportieren')
|
->label('Daten exportieren')
|
||||||
|
|||||||
61
app/Filament/Widgets/CreditAlertsWidget.php
Normal file
61
app/Filament/Widgets/CreditAlertsWidget.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
|
use App\Models\PurchaseHistory;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class CreditAlertsWidget extends StatsOverviewWidget
|
||||||
|
{
|
||||||
|
protected static ?int $sort = 0;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
protected function getCards(): array
|
||||||
|
{
|
||||||
|
$lowBalanceCount = Tenant::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->where('event_credits_balance', '<', 5)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$monthStart = now()->startOfMonth();
|
||||||
|
$monthlyRevenue = PurchaseHistory::query()
|
||||||
|
->where('purchased_at', '>=', $monthStart)
|
||||||
|
->sum('price');
|
||||||
|
|
||||||
|
$activeSubscriptions = Tenant::query()
|
||||||
|
->whereNotNull('subscription_expires_at')
|
||||||
|
->where('subscription_expires_at', '>', now())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make(
|
||||||
|
__('admin.widgets.credit_alerts.low_balance_label'),
|
||||||
|
$lowBalanceCount
|
||||||
|
)
|
||||||
|
->description(__('admin.widgets.credit_alerts.low_balance_desc'))
|
||||||
|
->descriptionIcon('heroicon-m-exclamation-triangle')
|
||||||
|
->color('warning')
|
||||||
|
->url(route('filament.superadmin.resources.tenants.index')),
|
||||||
|
Stat::make(
|
||||||
|
__('admin.widgets.credit_alerts.monthly_revenue_label'),
|
||||||
|
number_format((float) $monthlyRevenue, 2).' €'
|
||||||
|
)
|
||||||
|
->description(__('admin.widgets.credit_alerts.monthly_revenue_desc', [
|
||||||
|
'month' => $monthStart->translatedFormat('F'),
|
||||||
|
]))
|
||||||
|
->descriptionIcon('heroicon-m-currency-euro')
|
||||||
|
->color('success'),
|
||||||
|
Stat::make(
|
||||||
|
__('admin.widgets.credit_alerts.active_subscriptions_label'),
|
||||||
|
$activeSubscriptions
|
||||||
|
)
|
||||||
|
->description(__('admin.widgets.credit_alerts.active_subscriptions_desc'))
|
||||||
|
->descriptionIcon('heroicon-m-arrow-trending-up')
|
||||||
|
->color('info'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
55
app/Filament/Widgets/RevenueTrendWidget.php
Normal file
55
app/Filament/Widgets/RevenueTrendWidget.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
|
use App\Models\PurchaseHistory;
|
||||||
|
use Filament\Widgets\LineChartWidget;
|
||||||
|
|
||||||
|
class RevenueTrendWidget extends LineChartWidget
|
||||||
|
{
|
||||||
|
|
||||||
|
protected static ?int $sort = 1;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
public function getHeading(): ?string
|
||||||
|
{
|
||||||
|
return __('admin.widgets.revenue_trend.heading');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getData(): array
|
||||||
|
{
|
||||||
|
$start = now()->startOfMonth()->subMonths(11);
|
||||||
|
$months = collect(range(0, 11))->map(fn (int $offset) => $start->copy()->addMonths($offset));
|
||||||
|
|
||||||
|
$records = PurchaseHistory::query()
|
||||||
|
->where('purchased_at', '>=', $start)
|
||||||
|
->get(['purchased_at', 'price']);
|
||||||
|
|
||||||
|
$grouped = $records->groupBy(fn (PurchaseHistory $history) => $history->purchased_at?->format('Y-m'));
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
foreach ($months as $month) {
|
||||||
|
$key = $month->format('Y-m');
|
||||||
|
$labels[] = $month->translatedFormat('M Y');
|
||||||
|
$total = $grouped->get($key, collect())->sum(fn (PurchaseHistory $history) => (float) $history->price);
|
||||||
|
$values[] = round($total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'datasets' => [
|
||||||
|
[
|
||||||
|
'label' => __('admin.widgets.revenue_trend.series'),
|
||||||
|
'data' => $values,
|
||||||
|
'borderColor' => '#ec4899',
|
||||||
|
'backgroundColor' => 'rgba(236, 72, 153, 0.2)',
|
||||||
|
'tension' => 0.4,
|
||||||
|
'fill' => 'origin',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'labels' => $labels,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Filament/Widgets/TopTenantsByRevenue.php
Normal file
53
app/Filament/Widgets/TopTenantsByRevenue.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Widgets\TableWidget as BaseWidget;
|
||||||
|
|
||||||
|
class TopTenantsByRevenue extends BaseWidget
|
||||||
|
{
|
||||||
|
protected static ?string $heading = null;
|
||||||
|
|
||||||
|
protected static ?int $sort = 2;
|
||||||
|
|
||||||
|
protected ?string $pollingInterval = '120s';
|
||||||
|
|
||||||
|
protected function getHeading(): ?string
|
||||||
|
{
|
||||||
|
return __('admin.widgets.top_tenants_by_revenue.heading');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Tables\Table $table): Tables\Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(
|
||||||
|
Tenant::query()
|
||||||
|
->withSum('purchases', 'price')
|
||||||
|
->withCount('purchases')
|
||||||
|
->orderByDesc('purchases_sum_price')
|
||||||
|
->limit(10)
|
||||||
|
)
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label(__('admin.common.tenant'))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('purchases_sum_price')
|
||||||
|
->label(__('admin.widgets.top_tenants_by_revenue.total'))
|
||||||
|
->money('EUR')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('purchases_count')
|
||||||
|
->label(__('admin.widgets.top_tenants_by_revenue.count'))
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('event_credits_balance')
|
||||||
|
->label(__('admin.common.credits'))
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->paginated(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ use Illuminate\Support\Arr;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
class EventController extends Controller
|
class EventController extends Controller
|
||||||
{
|
{
|
||||||
@@ -124,9 +125,8 @@ class EventController extends Controller
|
|||||||
$event = DB::transaction(function () use ($tenant, $eventData, $packageId) {
|
$event = DB::transaction(function () use ($tenant, $eventData, $packageId) {
|
||||||
$event = Event::create($eventData);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
// Create EventPackage and PackagePurchase for Free package
|
|
||||||
$package = \App\Models\Package::findOrFail($packageId);
|
$package = \App\Models\Package::findOrFail($packageId);
|
||||||
$eventPackage = \App\Models\EventPackage::create([
|
\App\Models\EventPackage::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $packageId,
|
'package_id' => $packageId,
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
@@ -143,8 +143,9 @@ class EventController extends Controller
|
|||||||
'metadata' => json_encode(['note' => 'Free package assigned on event creation']),
|
'metadata' => json_encode(['note' => 'Free package assigned on event creation']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($tenant->activeResellerPackage) {
|
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||||
$tenant->incrementUsedEvents();
|
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||||
|
throw new HttpException(402, 'Insufficient credits or package allowance.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $event;
|
return $event;
|
||||||
|
|||||||
@@ -22,23 +22,35 @@ use Stripe\PaymentIntent;
|
|||||||
use Stripe\Stripe;
|
use Stripe\Stripe;
|
||||||
|
|
||||||
use App\Http\Controllers\PayPalController;
|
use App\Http\Controllers\PayPalController;
|
||||||
|
use App\Support\Concerns\PresentsPackages;
|
||||||
|
|
||||||
class CheckoutController extends Controller
|
class CheckoutController extends Controller
|
||||||
{
|
{
|
||||||
|
use PresentsPackages;
|
||||||
|
|
||||||
public function show(Package $package)
|
public function show(Package $package)
|
||||||
{
|
{
|
||||||
// Alle verfügbaren Pakete laden
|
$googleStatus = session()->pull('checkout_google_status');
|
||||||
$packages = Package::all();
|
$googleError = session()->pull('checkout_google_error');
|
||||||
|
|
||||||
|
$packageOptions = Package::orderBy('price')->get()
|
||||||
|
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
return Inertia::render('marketing/CheckoutWizardPage', [
|
return Inertia::render('marketing/CheckoutWizardPage', [
|
||||||
'package' => $package,
|
'package' => $this->presentPackage($package),
|
||||||
'packageOptions' => $packages,
|
'packageOptions' => $packageOptions,
|
||||||
'stripePublishableKey' => config('services.stripe.key'),
|
'stripePublishableKey' => config('services.stripe.key'),
|
||||||
'paypalClientId' => config('services.paypal.client_id'),
|
'paypalClientId' => config('services.paypal.client_id'),
|
||||||
'privacyHtml' => view('legal.datenschutz-partial')->render(),
|
'privacyHtml' => view('legal.datenschutz-partial')->render(),
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => Auth::user(),
|
'user' => Auth::user(),
|
||||||
],
|
],
|
||||||
|
'googleAuth' => [
|
||||||
|
'status' => $googleStatus,
|
||||||
|
'error' => $googleError,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +109,14 @@ class CheckoutController extends Controller
|
|||||||
'event_default_type' => 'general',
|
'event_default_type' => 'general',
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||||
// Package zuweisen
|
// Package zuweisen
|
||||||
$tenant->packages()->attach($package->id, [
|
$tenant->packages()->attach($package->id, [
|
||||||
|
'price' => $package->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'expires_at' => $package->is_free ? null : now()->addYear(),
|
'expires_at' => $this->packageIsFree($package) ? now()->addYear() : now()->addYear(),
|
||||||
'is_active' => $package->is_free, // Kostenlose Pakete sofort aktivieren
|
'active' => $this->packageIsFree($package), // Kostenlose Pakete sofort aktivieren
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// E-Mail-Verifizierung senden
|
// E-Mail-Verifizierung senden
|
||||||
@@ -241,7 +256,9 @@ class CheckoutController extends Controller
|
|||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($package->is_free) {
|
$isFreePackage = $this->packageIsFree($package);
|
||||||
|
|
||||||
|
if ($isFreePackage) {
|
||||||
\Log::info('Free package detected, returning null client_secret');
|
\Log::info('Free package detected, returning null client_secret');
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'client_secret' => null,
|
'client_secret' => null,
|
||||||
@@ -305,9 +322,10 @@ class CheckoutController extends Controller
|
|||||||
|
|
||||||
// Package dem Tenant zuweisen
|
// Package dem Tenant zuweisen
|
||||||
$user->tenant->packages()->attach($package->id, [
|
$user->tenant->packages()->attach($package->id, [
|
||||||
|
'price' => $package->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'expires_at' => now()->addYear(),
|
'expires_at' => now()->addYear(),
|
||||||
'is_active' => true,
|
'active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// pending_purchase zurücksetzen
|
// pending_purchase zurücksetzen
|
||||||
@@ -362,9 +380,10 @@ class CheckoutController extends Controller
|
|||||||
|
|
||||||
// TenantPackage zuweisen (ähnlich Stripe)
|
// TenantPackage zuweisen (ähnlich Stripe)
|
||||||
$user->tenant->packages()->attach($package->id, [
|
$user->tenant->packages()->attach($package->id, [
|
||||||
|
'price' => $package->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'expires_at' => now()->addYear(),
|
'expires_at' => now()->addYear(),
|
||||||
'is_active' => true,
|
'active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// pending_purchase zurücksetzen
|
// pending_purchase zurücksetzen
|
||||||
@@ -379,4 +398,15 @@ class CheckoutController extends Controller
|
|||||||
return redirect('/checkout')->with('error', 'Fehler beim Abschließen der Zahlung: ' . $e->getMessage());
|
return redirect('/checkout')->with('error', 'Fehler beim Abschließen der Zahlung: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function packageIsFree(Package $package): bool
|
||||||
|
{
|
||||||
|
if (isset($package->is_free)) {
|
||||||
|
return (bool) $package->is_free;
|
||||||
|
}
|
||||||
|
|
||||||
|
$price = (float) $package->price;
|
||||||
|
|
||||||
|
return $price <= 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
211
app/Http/Controllers/CheckoutGoogleController.php
Normal file
211
app/Http/Controllers/CheckoutGoogleController.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Mail\Welcome;
|
||||||
|
use App\Models\Package;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
|
||||||
|
class CheckoutGoogleController extends Controller
|
||||||
|
{
|
||||||
|
private const SESSION_KEY = 'checkout_google_payload';
|
||||||
|
|
||||||
|
public function redirect(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'package_id' => ['required', 'exists:packages,id'],
|
||||||
|
'locale' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'package_id' => (int) $validated['package_id'],
|
||||||
|
'locale' => $validated['locale'] ?? app()->getLocale(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$request->session()->put(self::SESSION_KEY, $payload);
|
||||||
|
$request->session()->put('selected_package_id', $payload['package_id']);
|
||||||
|
|
||||||
|
return Socialite::driver('google')
|
||||||
|
->scopes(['email', 'profile'])
|
||||||
|
->with(['prompt' => 'select_account'])
|
||||||
|
->redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$payload = $request->session()->get(self::SESSION_KEY, []);
|
||||||
|
$packageId = $payload['package_id'] ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$googleUser = Socialite::driver('google')->user();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Google checkout login failed', ['message' => $e->getMessage()]);
|
||||||
|
$this->flashError($request, __('checkout.google_error_fallback'));
|
||||||
|
return $this->redirectBackToWizard($packageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = $googleUser->getEmail();
|
||||||
|
if (! $email) {
|
||||||
|
$this->flashError($request, __('checkout.google_missing_email'));
|
||||||
|
return $this->redirectBackToWizard($packageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = DB::transaction(function () use ($googleUser, $email) {
|
||||||
|
$existing = User::where('email', $email)->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existing->forceFill([
|
||||||
|
'name' => $googleUser->getName() ?: $existing->name,
|
||||||
|
'pending_purchase' => true,
|
||||||
|
'email_verified_at' => $existing->email_verified_at ?? now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if (! $existing->tenant) {
|
||||||
|
$this->createTenantForUser($existing, $googleUser->getName(), $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existing->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $googleUser->getName(),
|
||||||
|
'email' => $email,
|
||||||
|
'password' => Hash::make(Str::random(32)),
|
||||||
|
'pending_purchase' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
event(new Registered($user));
|
||||||
|
|
||||||
|
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Mail::to($user)->queue(new Welcome($user));
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::warning('Failed to queue welcome mail after Google signup', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tap($user)->setRelation('tenant', $tenant);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! $user->tenant) {
|
||||||
|
$this->createTenantForUser($user, $googleUser->getName(), $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::login($user, true);
|
||||||
|
$request->session()->regenerate();
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
$request->session()->put('checkout_google_status', 'success');
|
||||||
|
|
||||||
|
if ($packageId) {
|
||||||
|
$this->ensurePackageAttached($user, (int) $packageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectBackToWizard($packageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant
|
||||||
|
{
|
||||||
|
$tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant';
|
||||||
|
$slugBase = Str::slug($tenantName) ?: 'tenant';
|
||||||
|
$slug = $slugBase;
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
while (Tenant::where('slug', $slug)->exists()) {
|
||||||
|
$slug = $slugBase . '-' . $counter;
|
||||||
|
$counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => $tenantName,
|
||||||
|
'slug' => $slug,
|
||||||
|
'email' => $email,
|
||||||
|
'contact_email' => $email,
|
||||||
|
'is_active' => true,
|
||||||
|
'is_suspended' => false,
|
||||||
|
'event_credits_balance' => 0,
|
||||||
|
'subscription_tier' => 'free',
|
||||||
|
'subscription_status' => 'free',
|
||||||
|
'subscription_expires_at' => null,
|
||||||
|
'settings' => json_encode([
|
||||||
|
'branding' => [
|
||||||
|
'logo_url' => null,
|
||||||
|
'primary_color' => '#3B82F6',
|
||||||
|
'secondary_color' => '#1F2937',
|
||||||
|
'font_family' => 'Inter, sans-serif',
|
||||||
|
],
|
||||||
|
'features' => [
|
||||||
|
'photo_likes_enabled' => false,
|
||||||
|
'event_checklist' => false,
|
||||||
|
'custom_domain' => false,
|
||||||
|
'advanced_analytics' => false,
|
||||||
|
],
|
||||||
|
'custom_domain' => null,
|
||||||
|
'contact_email' => $email,
|
||||||
|
'event_default_type' => 'general',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensurePackageAttached(User $user, int $packageId): void
|
||||||
|
{
|
||||||
|
$tenant = $user->tenant;
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = Package::find($packageId);
|
||||||
|
if (! $package) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->packages()->where('package_id', $packageId)->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->packages()->attach($packageId, [
|
||||||
|
'price' => $package->price,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
'expires_at' => now()->addYear(),
|
||||||
|
'active' => $package->price <= 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirectBackToWizard(?int $packageId): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($packageId) {
|
||||||
|
return redirect()->route('purchase.wizard', ['package' => $packageId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstPackageId = Package::query()->orderBy('price')->value('id');
|
||||||
|
if ($firstPackageId) {
|
||||||
|
return redirect()->route('purchase.wizard', ['package' => $firstPackageId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('packages');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flashError(Request $request, string $message): void
|
||||||
|
{
|
||||||
|
$request->session()->flash('checkout_google_error', $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,9 +29,12 @@ use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
|||||||
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
||||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||||
use League\CommonMark\MarkdownConverter;
|
use League\CommonMark\MarkdownConverter;
|
||||||
|
use App\Support\Concerns\PresentsPackages;
|
||||||
|
|
||||||
class MarketingController extends Controller
|
class MarketingController extends Controller
|
||||||
{
|
{
|
||||||
|
use PresentsPackages;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
Stripe::setApiKey(config('services.stripe.key'));
|
Stripe::setApiKey(config('services.stripe.key'));
|
||||||
@@ -39,9 +42,12 @@ class MarketingController extends Controller
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$packages = Package::where('type', 'endcustomer')->orderBy('price')->get()->map(function ($p) {
|
$packages = Package::where('type', 'endcustomer')
|
||||||
return $p->append(['features', 'limits']);
|
->orderBy('price')
|
||||||
});
|
->get()
|
||||||
|
->map(fn (Package $package) => $this->presentPackage($package))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
return Inertia::render('marketing/Home', compact('packages'));
|
return Inertia::render('marketing/Home', compact('packages'));
|
||||||
}
|
}
|
||||||
@@ -484,13 +490,15 @@ class MarketingController extends Controller
|
|||||||
->orderBy('price')
|
->orderBy('price')
|
||||||
->get()
|
->get()
|
||||||
->map(fn (Package $package) => $this->presentPackage($package))
|
->map(fn (Package $package) => $this->presentPackage($package))
|
||||||
->values();
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
$resellerPackages = Package::where('type', 'reseller')
|
$resellerPackages = Package::where('type', 'reseller')
|
||||||
->orderBy('price')
|
->orderBy('price')
|
||||||
->get()
|
->get()
|
||||||
->map(fn (Package $package) => $this->presentPackage($package))
|
->map(fn (Package $package) => $this->presentPackage($package))
|
||||||
->values();
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
return Inertia::render('marketing/Packages', [
|
return Inertia::render('marketing/Packages', [
|
||||||
'endcustomerPackages' => $endcustomerPackages,
|
'endcustomerPackages' => $endcustomerPackages,
|
||||||
@@ -516,170 +524,4 @@ class MarketingController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('marketing/Occasions', ['type' => $type]);
|
return Inertia::render('marketing/Occasions', ['type' => $type]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function presentPackage(Package $package): array
|
|
||||||
{
|
|
||||||
$package->append('limits');
|
|
||||||
|
|
||||||
$packageArray = $package->toArray();
|
|
||||||
$features = $packageArray['features'] ?? [];
|
|
||||||
$features = $this->normaliseFeatures($features);
|
|
||||||
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
$name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale);
|
|
||||||
$descriptionTemplate = $this->resolveTranslation($package->description_translations ?? null, $package->description ?? '', $locale);
|
|
||||||
|
|
||||||
$replacements = $this->buildPlaceholderReplacements($package);
|
|
||||||
|
|
||||||
$description = trim($this->applyPlaceholders($descriptionTemplate, $replacements));
|
|
||||||
|
|
||||||
$table = $package->description_table ?? [];
|
|
||||||
if (is_string($table)) {
|
|
||||||
$decoded = json_decode($table, true);
|
|
||||||
$table = is_array($decoded) ? $decoded : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$table = array_map(function (array $row) use ($replacements) {
|
|
||||||
return [
|
|
||||||
'title' => trim($this->applyPlaceholders($row['title'] ?? '', $replacements)),
|
|
||||||
'value' => trim($this->applyPlaceholders($row['value'] ?? '', $replacements)),
|
|
||||||
];
|
|
||||||
}, $table);
|
|
||||||
$table = array_values($table);
|
|
||||||
|
|
||||||
$galleryDuration = $replacements['{{gallery_duration}}'] ?? null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $package->id,
|
|
||||||
'name' => $name,
|
|
||||||
'slug' => $package->slug,
|
|
||||||
'type' => $package->type,
|
|
||||||
'price' => $package->price,
|
|
||||||
'description' => $description,
|
|
||||||
'description_breakdown' => $table,
|
|
||||||
'gallery_duration_label' => $galleryDuration,
|
|
||||||
'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null),
|
|
||||||
'features' => $features,
|
|
||||||
'limits' => $package->limits,
|
|
||||||
'max_photos' => $package->max_photos,
|
|
||||||
'max_guests' => $package->max_guests,
|
|
||||||
'max_tasks' => $package->max_tasks,
|
|
||||||
'gallery_days' => $package->gallery_days,
|
|
||||||
'max_events_per_year' => $package->max_events_per_year,
|
|
||||||
'watermark_allowed' => (bool) $package->watermark_allowed,
|
|
||||||
'branding_allowed' => (bool) $package->branding_allowed,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildPlaceholderReplacements(Package $package): array
|
|
||||||
{
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'{{max_photos}}' => $this->formatCount($package->max_photos, [
|
|
||||||
'de' => 'unbegrenzt viele',
|
|
||||||
'en' => 'unlimited',
|
|
||||||
]),
|
|
||||||
'{{max_guests}}' => $this->formatCount($package->max_guests, [
|
|
||||||
'de' => 'beliebig viele',
|
|
||||||
'en' => 'any number of',
|
|
||||||
]),
|
|
||||||
'{{max_tasks}}' => $this->formatCount($package->max_tasks, [
|
|
||||||
'de' => 'individuelle',
|
|
||||||
'en' => 'custom',
|
|
||||||
]),
|
|
||||||
'{{max_events_per_year}}' => $this->formatCount($package->max_events_per_year, [
|
|
||||||
'de' => 'unbegrenzte',
|
|
||||||
'en' => 'unlimited',
|
|
||||||
]),
|
|
||||||
'{{gallery_duration}}' => $this->formatGalleryDuration($package->gallery_days),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyPlaceholders(string $template, array $replacements): string
|
|
||||||
{
|
|
||||||
if ($template === '') {
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatCount(?int $value, array $fallbackByLocale): string
|
|
||||||
{
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
|
|
||||||
if ($value === null) {
|
|
||||||
return $fallbackByLocale[$locale] ?? reset($fallbackByLocale) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$decimal = $locale === 'de' ? ',' : '.';
|
|
||||||
$thousands = $locale === 'de' ? '.' : ',';
|
|
||||||
|
|
||||||
return number_format($value, 0, $decimal, $thousands);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatGalleryDuration(?int $days): string
|
|
||||||
{
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
|
|
||||||
if (!$days || $days <= 0) {
|
|
||||||
return $locale === 'en' ? 'permanent' : 'dauerhaft';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($days % 30 === 0) {
|
|
||||||
$months = (int) ($days / 30);
|
|
||||||
if ($locale === 'en') {
|
|
||||||
return $months === 1 ? '1 month' : $months . ' months';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $months === 1 ? '1 Monat' : $months . ' Monate';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $locale === 'en' ? $days . ' days' : $days . ' Tage';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normaliseFeatures(mixed $features): array
|
|
||||||
{
|
|
||||||
if (is_string($features)) {
|
|
||||||
$decoded = json_decode($features, true);
|
|
||||||
if (json_last_error() === JSON_ERROR_NONE) {
|
|
||||||
$features = $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($features)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
foreach ($features as $key => $value) {
|
|
||||||
if (is_string($value)) {
|
|
||||||
$list[] = $value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($key) && (bool) $value) {
|
|
||||||
$list[] = $key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique(array_filter($list, fn ($item) => is_string($item) && $item !== '')));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveTranslation(mixed $value, string $fallback, string $locale): string
|
|
||||||
{
|
|
||||||
if (is_string($value)) {
|
|
||||||
$decoded = json_decode($value, true);
|
|
||||||
if (json_last_error() === JSON_ERROR_NONE) {
|
|
||||||
$value = $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
return trim((string) ($value[$locale] ?? $value['en'] ?? $value['de'] ?? $fallback));
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim((string) ($value ?? $fallback));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Models\TenantToken;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -22,7 +23,7 @@ class OAuthController extends Controller
|
|||||||
private const AUTH_CODE_TTL_MINUTES = 5;
|
private const AUTH_CODE_TTL_MINUTES = 5;
|
||||||
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
||||||
private const REFRESH_TOKEN_TTL_DAYS = 30;
|
private const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||||
private const TOKEN_HEADER_KID = 'fotospiel-jwt';
|
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorize endpoint - PKCE flow
|
* Authorize endpoint - PKCE flow
|
||||||
@@ -286,9 +287,16 @@ class OAuthController extends Controller
|
|||||||
$storedIp = (string) ($storedRefreshToken->ip_address ?? '');
|
$storedIp = (string) ($storedRefreshToken->ip_address ?? '');
|
||||||
$currentIp = (string) ($request->ip() ?? '');
|
$currentIp = (string) ($request->ip() ?? '');
|
||||||
|
|
||||||
if ($storedIp !== '' && $currentIp !== '' && ! hash_equals($storedIp, $currentIp)) {
|
if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
|
||||||
$storedRefreshToken->update(['revoked_at' => now()]);
|
$storedRefreshToken->update(['revoked_at' => now()]);
|
||||||
|
|
||||||
|
Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [
|
||||||
|
'client_id' => $request->client_id,
|
||||||
|
'refresh_token_id' => $storedRefreshToken->id,
|
||||||
|
'stored_ip' => $storedIp,
|
||||||
|
'current_ip' => $currentIp,
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
|
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +395,7 @@ class OAuthController extends Controller
|
|||||||
int $issuedAt,
|
int $issuedAt,
|
||||||
int $expiresAt
|
int $expiresAt
|
||||||
): string {
|
): string {
|
||||||
[$publicKey, $privateKey] = $this->ensureKeysExist();
|
[$kid, , $privateKey] = $this->getSigningKeyPair();
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'iss' => url('/'),
|
'iss' => url('/'),
|
||||||
@@ -403,47 +411,94 @@ class OAuthController extends Controller
|
|||||||
'jti' => $jti,
|
'jti' => $jti,
|
||||||
];
|
];
|
||||||
|
|
||||||
return JWT::encode($payload, $privateKey, 'RS256', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]);
|
return JWT::encode($payload, $privateKey, 'RS256', $kid, ['kid' => $kid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ensureKeysExist(): array
|
private function getSigningKeyPair(): array
|
||||||
{
|
{
|
||||||
$publicKeyPath = storage_path('app/public.key');
|
$kid = $this->currentKid();
|
||||||
$privateKeyPath = storage_path('app/private.key');
|
[$publicKey, $privateKey] = $this->ensureKeysForKid($kid);
|
||||||
|
|
||||||
$publicKey = @file_get_contents($publicKeyPath);
|
return [$kid, $publicKey, $privateKey];
|
||||||
$privateKey = @file_get_contents($privateKeyPath);
|
|
||||||
|
|
||||||
if ($publicKey && $privateKey) {
|
|
||||||
return [$publicKey, $privateKey];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->generateKeyPair();
|
private function currentKid(): string
|
||||||
|
{
|
||||||
|
return config('oauth.keys.current_kid', self::LEGACY_TOKEN_HEADER_KID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureKeysForKid(string $kid): array
|
||||||
|
{
|
||||||
|
$paths = $this->keyPaths($kid);
|
||||||
|
|
||||||
|
if (! File::exists($paths['directory'])) {
|
||||||
|
File::makeDirectory($paths['directory'], 0700, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->maybeMigrateLegacyKeys($paths);
|
||||||
|
|
||||||
|
if (! File::exists($paths['public']) || ! File::exists($paths['private'])) {
|
||||||
|
$this->generateKeyPair($paths['directory']);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
file_get_contents($publicKeyPath),
|
File::get($paths['public']),
|
||||||
file_get_contents($privateKeyPath),
|
File::get($paths['private']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateKeyPair(): void
|
private function keyPaths(string $kid): array
|
||||||
|
{
|
||||||
|
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||||
|
$directory = $base.DIRECTORY_SEPARATOR.$kid;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'directory' => $directory,
|
||||||
|
'public' => $directory.DIRECTORY_SEPARATOR.'public.key',
|
||||||
|
'private' => $directory.DIRECTORY_SEPARATOR.'private.key',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maybeMigrateLegacyKeys(array $paths): void
|
||||||
|
{
|
||||||
|
$legacyPublic = storage_path('app/public.key');
|
||||||
|
$legacyPrivate = storage_path('app/private.key');
|
||||||
|
|
||||||
|
if (! File::exists($paths['public']) && File::exists($legacyPublic)) {
|
||||||
|
File::copy($legacyPublic, $paths['public']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! File::exists($paths['private']) && File::exists($legacyPrivate)) {
|
||||||
|
File::copy($legacyPrivate, $paths['private']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateKeyPair(string $directory): void
|
||||||
{
|
{
|
||||||
$config = [
|
$config = [
|
||||||
'digest_alg' => OPENSSL_ALGO_SHA256,
|
'digest_alg' => OPENSSL_ALGO_SHA256,
|
||||||
'private_key_bits' => 2048,
|
'private_key_bits' => 4096,
|
||||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||||
];
|
];
|
||||||
|
|
||||||
$res = openssl_pkey_new($config);
|
$resource = openssl_pkey_new($config);
|
||||||
if (! $res) {
|
if (! $resource) {
|
||||||
throw new \RuntimeException('Failed to generate key pair');
|
throw new \RuntimeException('Failed to generate key pair');
|
||||||
}
|
}
|
||||||
|
|
||||||
openssl_pkey_export($res, $privKey);
|
openssl_pkey_export($resource, $privateKey);
|
||||||
$pubKey = openssl_pkey_get_details($res);
|
$details = openssl_pkey_get_details($resource);
|
||||||
|
$publicKey = $details['key'] ?? null;
|
||||||
|
|
||||||
file_put_contents(storage_path('app/private.key'), $privKey);
|
if (! $publicKey) {
|
||||||
file_put_contents(storage_path('app/public.key'), $pubKey['key']);
|
throw new \RuntimeException('Failed to extract public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey, true);
|
||||||
|
File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600);
|
||||||
|
|
||||||
|
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
|
||||||
|
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||||
}
|
}
|
||||||
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
|
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
|
||||||
{
|
{
|
||||||
@@ -480,6 +535,32 @@ class OAuthController extends Controller
|
|||||||
return response()->json($response, $status);
|
return response()->json($response, $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function ipMatches(string $storedIp, string $currentIp): bool
|
||||||
|
{
|
||||||
|
if ($storedIp === '' || $currentIp === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash_equals($storedIp, $currentIp)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! config('oauth.refresh_tokens.allow_subnet_match', false)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($currentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
$storedParts = explode('.', $storedIp);
|
||||||
|
$currentParts = explode('.', $currentIp);
|
||||||
|
|
||||||
|
return $storedParts[0] === $currentParts[0]
|
||||||
|
&& $storedParts[1] === $currentParts[1]
|
||||||
|
&& $storedParts[2] === $currentParts[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private function base64urlEncode(string $data): string
|
private function base64urlEncode(string $data): string
|
||||||
{
|
{
|
||||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use PaypalServerSdkLib\Models\Builders\AmountWithBreakdownBuilder;
|
|||||||
use PaypalServerSdkLib\Models\Builders\OrderApplicationContextBuilder;
|
use PaypalServerSdkLib\Models\Builders\OrderApplicationContextBuilder;
|
||||||
use PaypalServerSdkLib\Models\CheckoutPaymentIntent;
|
use PaypalServerSdkLib\Models\CheckoutPaymentIntent;
|
||||||
use App\Services\PayPal\PaypalClientFactory;
|
use App\Services\PayPal\PaypalClientFactory;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class PayPalController extends Controller
|
class PayPalController extends Controller
|
||||||
{
|
{
|
||||||
@@ -30,11 +31,18 @@ class PayPalController extends Controller
|
|||||||
public function createOrder(Request $request)
|
public function createOrder(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'tenant_id' => 'required|exists:tenants,id',
|
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
|
'tenant_id' => 'nullable|exists:tenants,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::findOrFail($request->tenant_id);
|
$tenant = $request->tenant_id
|
||||||
|
? Tenant::findOrFail($request->tenant_id)
|
||||||
|
: optional(Auth::user())->tenant;
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return response()->json(['error' => 'Tenant context required for checkout.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$package = Package::findOrFail($request->package_id);
|
$package = Package::findOrFail($request->package_id);
|
||||||
|
|
||||||
$ordersController = $this->client->getOrdersController();
|
$ordersController = $this->client->getOrdersController();
|
||||||
@@ -156,12 +164,18 @@ class PayPalController extends Controller
|
|||||||
public function createSubscription(Request $request)
|
public function createSubscription(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'tenant_id' => 'required|exists:tenants,id',
|
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'plan_id' => 'required', // PayPal plan ID for the package
|
'plan_id' => 'required|string',
|
||||||
|
'tenant_id' => 'nullable|exists:tenants,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::findOrFail($request->tenant_id);
|
$tenant = $request->tenant_id
|
||||||
|
? Tenant::findOrFail($request->tenant_id)
|
||||||
|
: optional(Auth::user())->tenant;
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return response()->json(['error' => 'Tenant context required for subscription checkout.'], 422);
|
||||||
|
}
|
||||||
$package = Package::findOrFail($request->package_id);
|
$package = Package::findOrFail($request->package_id);
|
||||||
|
|
||||||
$ordersController = $this->client->getOrdersController();
|
$ordersController = $this->client->getOrdersController();
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ class PayPalWebhookController extends Controller
|
|||||||
if ($tenantId) {
|
if ($tenantId) {
|
||||||
$tenant = Tenant::find($tenantId);
|
$tenant = Tenant::find($tenantId);
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$tenant->update(['subscription_status' => 'cancelled']);
|
$tenant->update(['subscription_status' => 'expired']);
|
||||||
// Deactivate TenantPackage
|
// Deactivate TenantPackage
|
||||||
TenantPackage::where('tenant_id', $tenantId)->update(['active' => false]);
|
TenantPackage::where('tenant_id', $tenantId)->update(['active' => false]);
|
||||||
Log::info('PayPal subscription cancelled', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]);
|
Log::info('PayPal subscription cancelled', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class PackageMiddleware
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresPackageCheck($request) && !$this->canPerformAction($request, $tenant)) {
|
if ($this->requiresPackageCheck($request) && ! $this->canPerformAction($request, $tenant)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'Package limits exceeded. Please purchase or upgrade a package.',
|
'error' => 'Package limits exceeded. Please purchase or upgrade a package.',
|
||||||
], 402);
|
], 402);
|
||||||
@@ -36,35 +36,30 @@ class PackageMiddleware
|
|||||||
{
|
{
|
||||||
return $request->isMethod('post') && (
|
return $request->isMethod('post') && (
|
||||||
$request->routeIs('api.v1.tenant.events.store') ||
|
$request->routeIs('api.v1.tenant.events.store') ||
|
||||||
$request->routeIs('api.v1.tenant.photos.store') // Assuming photo upload route
|
$request->routeIs('api.v1.tenant.events.photos.store')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canPerformAction(Request $request, Tenant $tenant): bool
|
private function canPerformAction(Request $request, Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
if ($request->routeIs('api.v1.tenant.events.store')) {
|
if ($request->routeIs('api.v1.tenant.events.store')) {
|
||||||
// Check tenant package for event creation
|
return $tenant->hasEventAllowance();
|
||||||
$resellerPackage = $tenant->activeResellerPackage();
|
|
||||||
if ($resellerPackage) {
|
|
||||||
return $resellerPackage->used_events < $resellerPackage->package->max_events_per_year;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->routeIs('api.v1.tenant.photos.store')) {
|
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
|
||||||
$eventId = $request->input('event_id');
|
$eventId = $request->input('event_id');
|
||||||
if (!$eventId) {
|
if (! $eventId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$event = Event::findOrFail($eventId);
|
$event = Event::query()->find($eventId);
|
||||||
if ($event->tenant_id !== $tenant->id) {
|
if (! $event || $event->tenant_id !== $tenant->id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$eventPackage = $event->eventPackage;
|
$eventPackage = $event->eventPackage;
|
||||||
if (!$eventPackage) {
|
if (! $eventPackage) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return $eventPackage->used_photos < $eventPackage->package->max_photos;
|
return $eventPackage->used_photos < ($eventPackage->package->max_photos ?? PHP_INT_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\TenantToken;
|
|||||||
use Closure;
|
use Closure;
|
||||||
use Firebase\JWT\JWT;
|
use Firebase\JWT\JWT;
|
||||||
use Firebase\JWT\Key;
|
use Firebase\JWT\Key;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Auth\GenericUser;
|
use Illuminate\Auth\GenericUser;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -15,6 +16,8 @@ use Illuminate\Support\Str;
|
|||||||
|
|
||||||
class TenantTokenGuard
|
class TenantTokenGuard
|
||||||
{
|
{
|
||||||
|
private const LEGACY_KID = 'fotospiel-jwt';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*/
|
*/
|
||||||
@@ -104,7 +107,9 @@ class TenantTokenGuard
|
|||||||
*/
|
*/
|
||||||
private function decodeToken(string $token): array
|
private function decodeToken(string $token): array
|
||||||
{
|
{
|
||||||
$publicKey = file_get_contents(storage_path('app/public.key'));
|
$kid = $this->extractKid($token);
|
||||||
|
$publicKey = $this->loadPublicKeyForKid($kid);
|
||||||
|
|
||||||
if (! $publicKey) {
|
if (! $publicKey) {
|
||||||
throw new \Exception('JWT public key not found');
|
throw new \Exception('JWT public key not found');
|
||||||
}
|
}
|
||||||
@@ -114,6 +119,35 @@ class TenantTokenGuard
|
|||||||
return (array) $decoded;
|
return (array) $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function extractKid(string $token): ?string
|
||||||
|
{
|
||||||
|
$segments = explode('.', $token);
|
||||||
|
if (count($segments) < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decodedHeader = json_decode(base64_decode($segments[0]), true);
|
||||||
|
return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadPublicKeyForKid(?string $kid): ?string
|
||||||
|
{
|
||||||
|
$resolvedKid = $kid ?? config('oauth.keys.current_kid', self::LEGACY_KID);
|
||||||
|
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||||
|
$path = $base.DIRECTORY_SEPARATOR.$resolvedKid.DIRECTORY_SEPARATOR.'public.key';
|
||||||
|
|
||||||
|
if (File::exists($path)) {
|
||||||
|
return File::get($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyPath = storage_path('app/public.key');
|
||||||
|
if (File::exists($legacyPath)) {
|
||||||
|
return File::get($legacyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if token is blacklisted
|
* Check if token is blacklisted
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
|||||||
|
|
||||||
private ?string $eventId;
|
private ?string $eventId;
|
||||||
|
|
||||||
|
public int $tries = 5;
|
||||||
|
|
||||||
|
public int $backoff = 60;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $payload
|
* @param array<string, mixed> $payload
|
||||||
*/
|
*/
|
||||||
@@ -32,6 +37,8 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
|||||||
{
|
{
|
||||||
$this->payload = $payload;
|
$this->payload = $payload;
|
||||||
$this->eventId = $eventId !== '' ? $eventId : null;
|
$this->eventId = $eventId !== '' ? $eventId : null;
|
||||||
|
$this->queue = config('services.revenuecat.queue', 'webhooks');
|
||||||
|
$this->onQueue($this->queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class OAuthClient extends Model
|
class OAuthClient extends Model
|
||||||
{
|
{
|
||||||
@@ -23,10 +24,16 @@ class OAuthClient extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'id' => 'string',
|
'id' => 'string',
|
||||||
'tenant_id' => 'integer',
|
'tenant_id' => 'integer',
|
||||||
'scopes' => 'array',
|
|
||||||
'redirect_uris' => 'array',
|
'redirect_uris' => 'array',
|
||||||
|
'scopes' => 'array',
|
||||||
'is_active' => 'bool',
|
'is_active' => 'bool',
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use App\Models\TenantPackage;
|
||||||
use App\Models\EventCreditsLedger;
|
use App\Models\EventCreditsLedger;
|
||||||
|
|
||||||
class Tenant extends Model
|
class Tenant extends Model
|
||||||
@@ -55,6 +58,13 @@ class Tenant extends Model
|
|||||||
return $this->hasMany(TenantPackage::class);
|
return $this->hasMany(TenantPackage::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function packages(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Package::class, 'tenant_packages')
|
||||||
|
->withPivot(['price', 'purchased_at', 'expires_at', 'active'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
public function activeResellerPackage(): HasOne
|
public function activeResellerPackage(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(TenantPackage::class)->where('active', true);
|
return $this->hasOne(TenantPackage::class)->where('active', true);
|
||||||
@@ -62,18 +72,13 @@ class Tenant extends Model
|
|||||||
|
|
||||||
public function canCreateEvent(): bool
|
public function canCreateEvent(): bool
|
||||||
{
|
{
|
||||||
$package = $this->activeResellerPackage()->first();
|
return $this->hasEventAllowance();
|
||||||
if (!$package) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $package->canCreateEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function incrementUsedEvents(int $amount = 1): bool
|
public function incrementUsedEvents(int $amount = 1): bool
|
||||||
{
|
{
|
||||||
$package = $this->activeResellerPackage()->first();
|
$package = $this->getActiveResellerPackage();
|
||||||
if (!$package) {
|
if (! $package) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +113,13 @@ class Tenant extends Model
|
|||||||
'note' => $note,
|
'note' => $note,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::info('Tenant credits incremented', [
|
||||||
|
'tenant_id' => $this->id,
|
||||||
|
'delta' => $amount,
|
||||||
|
'reason' => $reason,
|
||||||
|
'purchase_id' => $purchaseId,
|
||||||
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +142,54 @@ class Tenant extends Model
|
|||||||
'note' => $note,
|
'note' => $note,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::info('Tenant credits decremented', [
|
||||||
|
'tenant_id' => $this->id,
|
||||||
|
'delta' => -$amount,
|
||||||
|
'reason' => $reason,
|
||||||
|
'purchase_id' => $purchaseId,
|
||||||
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasEventAllowance(): bool
|
||||||
|
{
|
||||||
|
$package = $this->getActiveResellerPackage();
|
||||||
|
if ($package && $package->canCreateEvent()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($this->event_credits_balance ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
|
||||||
|
{
|
||||||
|
$package = $this->getActiveResellerPackage();
|
||||||
|
if ($package && $package->canCreateEvent()) {
|
||||||
|
$package->increment('used_events', $amount);
|
||||||
|
|
||||||
|
Log::info('Tenant package usage recorded', [
|
||||||
|
'tenant_id' => $this->id,
|
||||||
|
'tenant_package_id' => $package->id,
|
||||||
|
'used_events' => $package->used_events,
|
||||||
|
'amount' => $amount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->decrementCredits($amount, $reason, $note);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveResellerPackage(): ?TenantPackage
|
||||||
|
{
|
||||||
|
return $this->activeResellerPackage()
|
||||||
|
->whereHas('package', fn ($query) => $query->where('type', 'reseller'))
|
||||||
|
->where('active', true)
|
||||||
|
->orderByDesc('expires_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
public function activeSubscription(): Attribute
|
public function activeSubscription(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
|
|||||||
38
app/Policies/OAuthClientPolicy.php
Normal file
38
app/Policies/OAuthClientPolicy.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\OAuthClient;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class OAuthClientPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, OAuthClient $oauthClient): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, OAuthClient $oauthClient): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, OAuthClient $oauthClient): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
23
app/Policies/PurchaseHistoryPolicy.php
Normal file
23
app/Policies/PurchaseHistoryPolicy.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\PurchaseHistory;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class PurchaseHistoryPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, PurchaseHistory $purchaseHistory): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
73
app/Policies/TenantPolicy.php
Normal file
73
app/Policies/TenantPolicy.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class TenantPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any models.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the model.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
if ($user->role === 'tenant_admin') {
|
||||||
|
return (int) $user->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create models.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the model.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the model.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom ability for adjusting credits.
|
||||||
|
*/
|
||||||
|
public function adjustCredits(User $user, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom ability for suspending a tenant.
|
||||||
|
*/
|
||||||
|
public function suspend(User $user, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'super_admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -53,6 +53,25 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
Inertia::share('locale', fn () => app()->getLocale());
|
Inertia::share('locale', fn () => app()->getLocale());
|
||||||
|
Inertia::share('analytics', static function () {
|
||||||
|
$config = config('services.matomo');
|
||||||
|
|
||||||
|
if (!($config['enabled'] ?? false)) {
|
||||||
|
return [
|
||||||
|
'matomo' => [
|
||||||
|
'enabled' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'matomo' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'url' => rtrim((string) ($config['url'] ?? ''), '/'),
|
||||||
|
'siteId' => (string) ($config['site_id'] ?? ''),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
if (config('storage-monitor.queue_failure_alerts')) {
|
if (config('storage-monitor.queue_failure_alerts')) {
|
||||||
Queue::failing(function (JobFailed $event) {
|
Queue::failing(function (JobFailed $event) {
|
||||||
|
|||||||
40
app/Providers/AuthServiceProvider.php
Normal file
40
app/Providers/AuthServiceProvider.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\OAuthClient;
|
||||||
|
use App\Models\PurchaseHistory;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Policies\OAuthClientPolicy;
|
||||||
|
use App\Policies\PurchaseHistoryPolicy;
|
||||||
|
use App\Policies\TenantPolicy;
|
||||||
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class AuthServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The policy mappings for the application.
|
||||||
|
*
|
||||||
|
* @var array<class-string, class-string>
|
||||||
|
*/
|
||||||
|
protected $policies = [
|
||||||
|
Tenant::class => TenantPolicy::class,
|
||||||
|
PurchaseHistory::class => PurchaseHistoryPolicy::class,
|
||||||
|
OAuthClient::class => OAuthClientPolicy::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register any authentication / authorization services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->registerPolicies();
|
||||||
|
|
||||||
|
Gate::before(function (User $user): ?bool {
|
||||||
|
return $user->role === 'super_admin' ? true : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,8 +18,11 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
|
|||||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||||
use Illuminate\Session\Middleware\StartSession;
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
|
use App\Filament\Widgets\CreditAlertsWidget;
|
||||||
use App\Filament\Widgets\PlatformStatsWidget;
|
use App\Filament\Widgets\PlatformStatsWidget;
|
||||||
|
use App\Filament\Widgets\RevenueTrendWidget;
|
||||||
use App\Filament\Widgets\TopTenantsByUploads;
|
use App\Filament\Widgets\TopTenantsByUploads;
|
||||||
|
use App\Filament\Widgets\TopTenantsByRevenue;
|
||||||
use App\Filament\Blog\Resources\PostResource;
|
use App\Filament\Blog\Resources\PostResource;
|
||||||
use App\Filament\Blog\Resources\CategoryResource;
|
use App\Filament\Blog\Resources\CategoryResource;
|
||||||
use App\Filament\Blog\Resources\AuthorResource;
|
use App\Filament\Blog\Resources\AuthorResource;
|
||||||
@@ -50,7 +53,10 @@ class SuperAdminPanelProvider extends PanelProvider
|
|||||||
->widgets([
|
->widgets([
|
||||||
Widgets\AccountWidget::class,
|
Widgets\AccountWidget::class,
|
||||||
Widgets\FilamentInfoWidget::class,
|
Widgets\FilamentInfoWidget::class,
|
||||||
|
CreditAlertsWidget::class,
|
||||||
|
RevenueTrendWidget::class,
|
||||||
PlatformStatsWidget::class,
|
PlatformStatsWidget::class,
|
||||||
|
TopTenantsByRevenue::class,
|
||||||
TopTenantsByUploads::class,
|
TopTenantsByUploads::class,
|
||||||
\App\Filament\Widgets\StorageCapacityWidget::class,
|
\App\Filament\Widgets\StorageCapacityWidget::class,
|
||||||
])
|
])
|
||||||
|
|||||||
173
app/Support/Concerns/PresentsPackages.php
Normal file
173
app/Support/Concerns/PresentsPackages.php
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Package;
|
||||||
|
|
||||||
|
trait PresentsPackages
|
||||||
|
{
|
||||||
|
protected function presentPackage(Package $package): array
|
||||||
|
{
|
||||||
|
$package->append('limits');
|
||||||
|
|
||||||
|
$packageArray = $package->toArray();
|
||||||
|
$features = $packageArray['features'] ?? [];
|
||||||
|
$features = $this->normaliseFeatures($features);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale);
|
||||||
|
$descriptionTemplate = $this->resolveTranslation($package->description_translations ?? null, $package->description ?? '', $locale);
|
||||||
|
|
||||||
|
$replacements = $this->buildPlaceholderReplacements($package);
|
||||||
|
$description = trim($this->applyPlaceholders($descriptionTemplate, $replacements));
|
||||||
|
|
||||||
|
$table = $package->description_table ?? [];
|
||||||
|
if (is_string($table)) {
|
||||||
|
$decoded = json_decode($table, true);
|
||||||
|
$table = is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = array_map(function (array $row) use ($replacements) {
|
||||||
|
return [
|
||||||
|
'title' => trim($this->applyPlaceholders($row['title'] ?? '', $replacements)),
|
||||||
|
'value' => trim($this->applyPlaceholders($row['value'] ?? '', $replacements)),
|
||||||
|
];
|
||||||
|
}, $table);
|
||||||
|
$table = array_values($table);
|
||||||
|
|
||||||
|
$galleryDuration = $replacements['{{gallery_duration}}'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $package->id,
|
||||||
|
'name' => $name,
|
||||||
|
'slug' => $package->slug,
|
||||||
|
'type' => $package->type,
|
||||||
|
'price' => $package->price,
|
||||||
|
'description' => $description,
|
||||||
|
'description_breakdown' => $table,
|
||||||
|
'gallery_duration_label' => $galleryDuration,
|
||||||
|
'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null),
|
||||||
|
'features' => $features,
|
||||||
|
'limits' => $package->limits,
|
||||||
|
'max_photos' => $package->max_photos,
|
||||||
|
'max_guests' => $package->max_guests,
|
||||||
|
'max_tasks' => $package->max_tasks,
|
||||||
|
'gallery_days' => $package->gallery_days,
|
||||||
|
'max_events_per_year' => $package->max_events_per_year,
|
||||||
|
'watermark_allowed' => (bool) $package->watermark_allowed,
|
||||||
|
'branding_allowed' => (bool) $package->branding_allowed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildPlaceholderReplacements(Package $package): array
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'{{max_photos}}' => $this->formatCount($package->max_photos, [
|
||||||
|
'de' => 'unbegrenzt viele',
|
||||||
|
'en' => 'unlimited',
|
||||||
|
]),
|
||||||
|
'{{max_guests}}' => $this->formatCount($package->max_guests, [
|
||||||
|
'de' => 'beliebig viele',
|
||||||
|
'en' => 'any number of',
|
||||||
|
]),
|
||||||
|
'{{max_tasks}}' => $this->formatCount($package->max_tasks, [
|
||||||
|
'de' => 'individuelle',
|
||||||
|
'en' => 'custom',
|
||||||
|
]),
|
||||||
|
'{{max_events_per_year}}' => $this->formatCount($package->max_events_per_year, [
|
||||||
|
'de' => 'unbegrenzte',
|
||||||
|
'en' => 'unlimited',
|
||||||
|
]),
|
||||||
|
'{{gallery_duration}}' => $this->formatGalleryDuration($package->gallery_days),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applyPlaceholders(string $template, array $replacements): string
|
||||||
|
{
|
||||||
|
if ($template === '') {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatCount(?int $value, array $fallbackByLocale): string
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return $fallbackByLocale[$locale] ?? reset($fallbackByLocale) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$decimal = $locale === 'de' ? ',' : '.';
|
||||||
|
$thousands = $locale === 'de' ? '.' : ',';
|
||||||
|
|
||||||
|
return number_format($value, 0, $decimal, $thousands);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatGalleryDuration(?int $days): string
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
if (!$days || $days <= 0) {
|
||||||
|
return $locale === 'en' ? 'permanent' : 'dauerhaft';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($days % 30 === 0) {
|
||||||
|
$months = (int) ($days / 30);
|
||||||
|
if ($locale === 'en') {
|
||||||
|
return $months === 1 ? '1 month' : $months . ' months';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $months === 1 ? '1 Monat' : $months . ' Monate';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $locale === 'en' ? $days . ' days' : $days . ' Tage';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normaliseFeatures(mixed $features): array
|
||||||
|
{
|
||||||
|
if (is_string($features)) {
|
||||||
|
$decoded = json_decode($features, true);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
$features = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($features)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
foreach ($features as $key => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$list[] = $value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($key) && (bool) $value) {
|
||||||
|
$list[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter($list, fn ($item) => is_string($item) && $item !== '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveTranslation(mixed $value, string $fallback, string $locale): string
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
$value = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
return trim((string) ($value[$locale] ?? $value['en'] ?? $value['de'] ?? $fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) ($value ?? $fallback));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
->withCommands([
|
||||||
|
\App\Console\Commands\OAuthRotateKeysCommand::class,
|
||||||
|
])
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'tenant.token' => TenantTokenGuard::class,
|
'tenant.token' => TenantTokenGuard::class,
|
||||||
@@ -25,6 +28,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'package.check' => \App\Http\Middleware\PackageMiddleware::class,
|
'package.check' => \App\Http\Middleware\PackageMiddleware::class,
|
||||||
'locale' => \App\Http\Middleware\SetLocale::class,
|
'locale' => \App\Http\Middleware\SetLocale::class,
|
||||||
'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class,
|
'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class,
|
||||||
|
'credit.check' => CreditCheckMiddleware::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\AuthServiceProvider::class,
|
||||||
Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class,
|
Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class,
|
||||||
App\Providers\Filament\SuperAdminPanelProvider::class,
|
App\Providers\Filament\SuperAdminPanelProvider::class,
|
||||||
App\Providers\Filament\AdminPanelProvider::class,
|
App\Providers\Filament\AdminPanelProvider::class,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.2",
|
"laravel/sanctum": "^4.2",
|
||||||
|
"laravel/socialite": "^5.23",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"laravel/wayfinder": "^0.1.9",
|
"laravel/wayfinder": "^0.1.9",
|
||||||
"league/commonmark": "^2.7",
|
"league/commonmark": "^2.7",
|
||||||
|
|||||||
310
composer.lock
generated
310
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7f7cd01c532ad63b7539234881b1169b",
|
"content-hash": "79b6c96efab0391868c6ce26689c0ce3",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -2962,6 +2962,78 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-22T17:29:40+00:00"
|
"time": "2025-09-22T17:29:40+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/socialite",
|
||||||
|
"version": "v5.23.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/socialite.git",
|
||||||
|
"reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5",
|
||||||
|
"reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"firebase/php-jwt": "^6.4",
|
||||||
|
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||||
|
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"league/oauth1-client": "^1.11",
|
||||||
|
"php": "^7.2|^8.0",
|
||||||
|
"phpseclib/phpseclib": "^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.0",
|
||||||
|
"orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0",
|
||||||
|
"phpstan/phpstan": "^1.12.23",
|
||||||
|
"phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Socialite": "Laravel\\Socialite\\Facades\\Socialite"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Socialite\\SocialiteServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "5.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Socialite\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.",
|
||||||
|
"homepage": "https://laravel.com",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"oauth"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/socialite/issues",
|
||||||
|
"source": "https://github.com/laravel/socialite"
|
||||||
|
},
|
||||||
|
"time": "2025-07-23T14:16:08+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/tinker",
|
"name": "laravel/tinker",
|
||||||
"version": "v2.10.1",
|
"version": "v2.10.1",
|
||||||
@@ -3559,6 +3631,82 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-09-21T08:32:55+00:00"
|
"time": "2024-09-21T08:32:55+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/oauth1-client",
|
||||||
|
"version": "v1.11.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/oauth1-client.git",
|
||||||
|
"reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055",
|
||||||
|
"reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||||
|
"guzzlehttp/psr7": "^1.7|^2.0",
|
||||||
|
"php": ">=7.1||>=8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^2.17",
|
||||||
|
"mockery/mockery": "^1.3.3",
|
||||||
|
"phpstan/phpstan": "^0.12.42",
|
||||||
|
"phpunit/phpunit": "^7.5||9.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-simplexml": "For decoding XML-based responses."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.0-dev",
|
||||||
|
"dev-develop": "2.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\OAuth1\\Client\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ben Corlett",
|
||||||
|
"email": "bencorlett@me.com",
|
||||||
|
"homepage": "http://www.webcomm.com.au",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "OAuth 1.0 Client Library",
|
||||||
|
"keywords": [
|
||||||
|
"Authentication",
|
||||||
|
"SSO",
|
||||||
|
"authorization",
|
||||||
|
"bitbucket",
|
||||||
|
"identity",
|
||||||
|
"idp",
|
||||||
|
"oauth",
|
||||||
|
"oauth1",
|
||||||
|
"single sign on",
|
||||||
|
"trello",
|
||||||
|
"tumblr",
|
||||||
|
"twitter"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/thephpleague/oauth1-client/issues",
|
||||||
|
"source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0"
|
||||||
|
},
|
||||||
|
"time": "2024-12-10T19:59:05+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "league/uri",
|
"name": "league/uri",
|
||||||
"version": "7.5.1",
|
"version": "7.5.1",
|
||||||
@@ -4696,6 +4844,56 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-24T15:06:41+00:00"
|
"time": "2025-09-24T15:06:41+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "paragonie/random_compat",
|
||||||
|
"version": "v9.99.100",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/paragonie/random_compat.git",
|
||||||
|
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||||
|
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">= 7"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "4.*|5.*",
|
||||||
|
"vimeo/psalm": "^1"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paragon Initiative Enterprises",
|
||||||
|
"email": "security@paragonie.com",
|
||||||
|
"homepage": "https://paragonie.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
|
||||||
|
"keywords": [
|
||||||
|
"csprng",
|
||||||
|
"polyfill",
|
||||||
|
"pseudorandom",
|
||||||
|
"random"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"email": "info@paragonie.com",
|
||||||
|
"issues": "https://github.com/paragonie/random_compat/issues",
|
||||||
|
"source": "https://github.com/paragonie/random_compat"
|
||||||
|
},
|
||||||
|
"time": "2020-10-15T08:29:30+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "paypal/paypal-server-sdk",
|
"name": "paypal/paypal-server-sdk",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -4961,6 +5159,116 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-08-21T11:53:16+00:00"
|
"time": "2025-08-21T11:53:16+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpseclib/phpseclib",
|
||||||
|
"version": "3.0.47",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/phpseclib/phpseclib.git",
|
||||||
|
"reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/9d6ca36a6c2dd434765b1071b2644a1c683b385d",
|
||||||
|
"reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"paragonie/constant_time_encoding": "^1|^2|^3",
|
||||||
|
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
|
||||||
|
"php": ">=5.6.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "*"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
|
||||||
|
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
|
||||||
|
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
|
||||||
|
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
|
||||||
|
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"phpseclib/bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"phpseclib3\\": "phpseclib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jim Wigginton",
|
||||||
|
"email": "terrafrost@php.net",
|
||||||
|
"role": "Lead Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Patrick Monnerat",
|
||||||
|
"email": "pm@datasphere.ch",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Andreas Fischer",
|
||||||
|
"email": "bantu@phpbb.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hans-Jürgen Petrich",
|
||||||
|
"email": "petrich@tronic-media.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Graham Campbell",
|
||||||
|
"email": "graham@alt-three.com",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
|
||||||
|
"homepage": "http://phpseclib.sourceforge.net",
|
||||||
|
"keywords": [
|
||||||
|
"BigInteger",
|
||||||
|
"aes",
|
||||||
|
"asn.1",
|
||||||
|
"asn1",
|
||||||
|
"blowfish",
|
||||||
|
"crypto",
|
||||||
|
"cryptography",
|
||||||
|
"encryption",
|
||||||
|
"rsa",
|
||||||
|
"security",
|
||||||
|
"sftp",
|
||||||
|
"signature",
|
||||||
|
"signing",
|
||||||
|
"ssh",
|
||||||
|
"twofish",
|
||||||
|
"x.509",
|
||||||
|
"x509"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/phpseclib/phpseclib/issues",
|
||||||
|
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.47"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/terrafrost",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.patreon.com/phpseclib",
|
||||||
|
"type": "patreon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-10-06T01:07:24+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpdoc-parser",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'enabled' => (bool) env('CHECKOUT_WIZARD_ENABLED', true),
|
||||||
|
'feature_flag' => env('CHECKOUT_WIZARD_FLAG', 'checkout-wizard-2025'),
|
||||||
'session_ttl_minutes' => env('CHECKOUT_SESSION_TTL', 30),
|
'session_ttl_minutes' => env('CHECKOUT_SESSION_TTL', 30),
|
||||||
'status_history_max' => env('CHECKOUT_STATUS_HISTORY_MAX', 25),
|
'status_history_max' => env('CHECKOUT_STATUS_HISTORY_MAX', 25),
|
||||||
];
|
];
|
||||||
13
config/oauth.php
Normal file
13
config/oauth.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'keys' => [
|
||||||
|
'current_kid' => env('OAUTH_JWT_KID', 'fotospiel-jwt'),
|
||||||
|
'storage_path' => env('OAUTH_KEY_STORE', storage_path('app/oauth-keys')),
|
||||||
|
],
|
||||||
|
'refresh_tokens' => [
|
||||||
|
'enforce_ip_binding' => env('OAUTH_REFRESH_ENFORCE_IP', true),
|
||||||
|
'allow_subnet_match' => env('OAUTH_REFRESH_ALLOW_SUBNET', false),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
@@ -43,6 +43,19 @@ return [
|
|||||||
'sandbox' => env('PAYPAL_SANDBOX', true),
|
'sandbox' => env('PAYPAL_SANDBOX', true),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'google' => [
|
||||||
|
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||||
|
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/') . '/checkout/auth/google/callback'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'revenuecat' => [
|
||||||
|
'webhook' => env('REVENUECAT_WEBHOOK_SECRET', ''),
|
||||||
|
'product_mappings' => env('REVENUECAT_PRODUCT_MAPPINGS', ''),
|
||||||
|
'app_user_prefix' => env('REVENUECAT_APP_USER_PREFIX', 'tenant'),
|
||||||
|
'queue' => env('REVENUECAT_WEBHOOK_QUEUE', 'webhooks'),
|
||||||
|
],
|
||||||
|
|
||||||
'oauth' => [
|
'oauth' => [
|
||||||
'tenant_admin' => [
|
'tenant_admin' => [
|
||||||
'id' => env('VITE_OAUTH_CLIENT_ID', 'tenant-admin-app'),
|
'id' => env('VITE_OAUTH_CLIENT_ID', 'tenant-admin-app'),
|
||||||
|
|||||||
@@ -7,21 +7,21 @@
|
|||||||
## Action Items
|
## Action Items
|
||||||
|
|
||||||
### Wizard Foundations
|
### Wizard Foundations
|
||||||
- [ ] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes.
|
- [x] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes (see `resources/js/pages/marketing/checkout/steps/PackageStep.tsx`).
|
||||||
- [ ] Redesign the payment step: Stripe and PayPal happy path, failure, retry; add subscription handling for reseller plans.
|
- [x] Redesign the payment step: Stripe and PayPal happy path, failure, retry; add subscription handling for reseller plans. *(Stripe intent lifecycle + PayPal subscription flow now share status alerts, retry logic, and plan gating in `PaymentStep.tsx`.)*
|
||||||
- [ ] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`.
|
- [x] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`. *(Handled via `ConfirmationStep.tsx` + wizard callbacks redirecting to `/settings/profile` and `/event-admin`.)*
|
||||||
|
|
||||||
### Authentication & Profile Data
|
### Authentication & Profile Data
|
||||||
- [x] Refactor `resources/js/pages/auth/LoginForm.tsx` and `RegisterForm.tsx` to hit the correct routes, surface inline validation errors, and provide success callbacks.
|
- [x] Refactor `resources/js/pages/auth/LoginForm.tsx` and `RegisterForm.tsx` to hit the correct routes, surface inline validation errors, and provide success callbacks.
|
||||||
- [ ] Add optional comfort login: Google sign-in and enrichment of missing registration fields via the payment provider, combining the prior step 2/3 concept.
|
- [x] Add optional comfort login: Google sign-in and enrichment of missing registration fields via the payment provider, combining the prior step 2/3 concept.
|
||||||
|
|
||||||
### Backend Alignment
|
### Backend Alignment
|
||||||
- [ ] Implement a dedicated `CheckoutController` plus marketing API routes, migrating any remaining checkout logic out of the marketing controller.
|
- [x] Implement a dedicated `CheckoutController` plus marketing API routes, migrating any remaining checkout logic out of the marketing controller. *(Controller + routes now live in `app/Http/Controllers/CheckoutController.php` / `routes/web.php`.)*
|
||||||
- [ ] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration.
|
- [x] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration. *(Legacy components removed; new wizard replaces them.)*
|
||||||
|
|
||||||
### Quality & Rollout
|
### Quality & Rollout
|
||||||
- [ ] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints.
|
- [x] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints. *(Feature + unit suites cover Stripe intents, PayPal webhooks, Google comfort login; Playwright CTA smoke in place—full payment journey available behind the `checkout` tag.)*
|
||||||
- [ ] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard.
|
- [x] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Wizard auth now uses `/checkout/login` and `/checkout/register` JSON endpoints handled by `CheckoutController`.
|
- Wizard auth now uses `/checkout/login` and `/checkout/register` JSON endpoints handled by `CheckoutController`.
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
### Payment Integration Plan
|
### Payment Integration Plan
|
||||||
- [x] Define provider-agnostic payment state machine (intent creation, approval, capture, failure). See docs/prp/marketing-checkout-payment-architecture.md.
|
- [x] Define provider-agnostic payment state machine (intent creation, approval, capture, failure). See docs/prp/marketing-checkout-payment-architecture.md.
|
||||||
- [x] Scaffold checkout_sessions migration + service layer per docs/prp/marketing-checkout-payment-architecture.md.
|
- [x] Scaffold checkout_sessions migration + service layer per docs/prp/marketing-checkout-payment-architecture.md.
|
||||||
- [ ] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session.
|
- [x] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session. *(Covered by `CheckoutController::createPaymentIntent`.)*
|
||||||
- [ ] Implement PayPal order creation/capture endpoints with metadata for tenant/package.
|
- [x] Implement PayPal order creation/capture endpoints with metadata for tenant/package. *(Routes now exposed in `routes/web.php`; controller derives tenant context for authenticated users.)*
|
||||||
- [ ] Add webhook handling matrix for Stripe invoice/payment events and PayPal subscription lifecycle.
|
- [x] Add webhook handling matrix for Stripe invoice/payment events and PayPal subscription lifecycle.
|
||||||
- [ ] Wire payment step UI to new endpoints with optimistic and retry handling.
|
- [x] Wire payment step UI to new endpoints with optimistic and retry handling. *(See `PaymentStep.tsx` for Stripe intent loading + PayPal order/subscription creation and capture callbacks.)*
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
### Update 2025-09-25
|
### Update 2025-10-21
|
||||||
- Phase 3 credits scope: credit middleware on create/update, rate limiters for OAuth/tenant APIs, RevenueCat webhook + processing job live with idempotency tests.
|
- Phase 3 credit scope delivered: tenant event creation now honours package allowances *and* credit balances (middleware + ledger logging), RevenueCat webhook signature checks ship with queue/backoff + env config, idempotency covered via unit tests.
|
||||||
- Remaining: extend middleware to photo/other credit drains, admin ledger surfaces, document RevenueCat env vars in PRP.
|
- Follow-up (separate): evaluate photo upload quota enforcement + SuperAdmin ledger visualisations once package analytics stabilise.
|
||||||
# Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt)
|
# Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt)
|
||||||
|
|
||||||
## Implementierungsstand (Aktualisiert: 2025-09-15)
|
## Implementierungsstand (Aktualisiert: 2025-09-15)
|
||||||
Basierend auf aktueller Code-Analyse und Implementierung:
|
Basierend auf aktueller Code-Analyse und Implementierung:
|
||||||
- **Phase 1 (Foundation)**: ✅ Vollständig abgeschlossen – Migrationen ausgeführt, Sanctum konfiguriert, OAuthController (PKCE-Flow, JWT), Middleware (TenantTokenGuard, TenantIsolation) implementiert und registriert.
|
- **Phase 1 (Foundation)**: ✅ Vollständig abgeschlossen – Migrationen ausgeführt, Sanctum konfiguriert, OAuthController (PKCE-Flow, JWT), Middleware (TenantTokenGuard, TenantIsolation) implementiert und registriert.
|
||||||
- **Phase 2 (Core API)**: ✅ 100% abgeschlossen – EventController (CRUD, Credit-Check, Search, Bulk), PhotoController (Upload, Moderation, Stats, Presigned Upload), **TaskController (CRUD, Event-Assignment, Bulk-Operations, Search)**, **SettingsController (Branding, Features, Custom Domain, Domain-Validation)**, Request/Response Models (EventStoreRequest, PhotoStoreRequest, **TaskStoreRequest, TaskUpdateRequest, SettingsStoreRequest**), Resources (**TaskResource, EventTypeResource**), File Upload Pipeline (local Storage, Thumbnails via ImageHelper), API-Routen erweitert, **Feature-Tests (21 Tests, 100% Coverage)**, **TenantModelTest (11 Unit-Tests)**.
|
- **Phase 2 (Core API)**: ✅ 100% abgeschlossen – EventController (CRUD, Credit-Check, Search, Bulk), PhotoController (Upload, Moderation, Stats, Presigned Upload), **TaskController (CRUD, Event-Assignment, Bulk-Operations, Search)**, **SettingsController (Branding, Features, Custom Domain, Domain-Validation)**, Request/Response Models (EventStoreRequest, PhotoStoreRequest, **TaskStoreRequest, TaskUpdateRequest, SettingsStoreRequest**), Resources (**TaskResource, EventTypeResource**), File Upload Pipeline (local Storage, Thumbnails via ImageHelper), API-Routen erweitert, **Feature-Tests (21 Tests, 100% Coverage)**, **TenantModelTest (11 Unit-Tests)**.
|
||||||
- **Phase 3 (Business Logic)**: 40% implementiert – event_credits_balance Feld vorhanden, Credit-Check in EventController, **Tenant::decrementCredits()/incrementCredits() Methoden**, aber CreditMiddleware, CreditController, Webhooks fehlen.
|
- **Phase 3 (Business Logic)**: 60% implementiert – event_credits_balance Feld vorhanden, Endpunkt/Controller stehen, Credit-Middleware aktiv, RevenueCat Webhook inkl. Queue/Retries produktionsreif; Token-Rotation folgt.
|
||||||
- **Phase 4 (Admin & Monitoring)**: 20% implementiert – **TenantResource erweitert (credits, features, activeSubscription)**, aber fehlend: subscription_tier Actions, PurchaseHistoryResource, Widgets, Policies.
|
- **Phase 4 (Admin & Monitoring)**: 45% implementiert – **TenantResource erweitert (credits, features, activeSubscription)**, PurchaseHistory/OAuthClient-Management sowie Dashboard-Widgets fertig; verbleibend sind Advanced Actions (subscription_tier) und erweiterte Monitoring-Policies.
|
||||||
|
|
||||||
**Gesamtaufwand reduziert**: Von 2-3 Wochen auf **4-5 Tage**, da Phase 2 vollständig abgeschlossen und Tests implementiert.
|
**Gesamtaufwand reduziert**: Von 2-3 Wochen auf **4-5 Tage**, da Phase 2 vollständig abgeschlossen und Tests implementiert.
|
||||||
|
|
||||||
@@ -65,31 +65,21 @@ Basierend auf aktueller Code-Analyse und Implementierung:
|
|||||||
|
|
||||||
### Implementierter Fortschritt
|
### Implementierter Fortschritt
|
||||||
- [x] Credit-Feld in Tenant-Model mit `event_credits_balance`
|
- [x] Credit-Feld in Tenant-Model mit `event_credits_balance`
|
||||||
- [x] **Tenant::decrementCredits()/incrementCredits() Methoden** implementiert
|
- [x] **Tenant::decrementCredits()/incrementCredits() Methoden** inkl. Logging implementiert
|
||||||
- [x] Credit-Check in EventController (decrement bei Create)
|
- [x] Credit-Middleware & Route-Alias greifen vor Event-Create; `Tenant::consumeEventAllowance()` nutzt zuerst Reseller-Pakete, sonst Credits
|
||||||
- [ ] CreditMiddleware für alle Event-Operationen
|
- [x] RevenueCat-Webhook: Signatur-Validierung, Queue-Konfiguration, Retry (`tries/backoff`) + Produkt-Mapping
|
||||||
|
|
||||||
### Verbleibende Tasks
|
### Verbleibende Tasks
|
||||||
1. **Credit-System erweitern (1 Tag)**
|
1. **Security Implementation (1 Tag)**
|
||||||
- CreditMiddleware für alle Event-Create/Update
|
- Rate Limiting: 100/min tenant, 10/min oauth *(aktiv)*
|
||||||
- CreditController für Balance, Ledger, History
|
- Token-Rotation in OAuthController *(KID-basierte Schlüssel & `oauth:rotate-keys`)*
|
||||||
- Tenant::decrementCredits() Methode mit Logging
|
- IP-Binding für Refresh Tokens *(konfigurierbar, Subnetzrelax optional)*
|
||||||
|
|
||||||
2. **Webhook-Integration (1-2 Tage)**
|
|
||||||
- RevenueCatController für Purchase-Webhooks
|
|
||||||
- Signature-Validation, Balance-Update, Subscription-Sync
|
|
||||||
- Queue-basierte Retry-Logic
|
|
||||||
|
|
||||||
3. **Security Implementation (1 Tag)**
|
|
||||||
- Rate Limiting: 100/min tenant, 10/min oauth
|
|
||||||
- Token-Rotation in OAuthController
|
|
||||||
- IP-Binding für Refresh Tokens
|
|
||||||
|
|
||||||
### Milestones
|
### Milestones
|
||||||
- [x] Credit-Check funktioniert (Event-Create scheitert bei 0)
|
- [x] Credit-Check funktioniert (Event-Create scheitert bei 0)
|
||||||
- [ ] Webhooks verarbeiten Purchases
|
- [x] Webhooks verarbeiten Purchases
|
||||||
- [ ] Rate Limiting aktiv
|
- [x] Rate Limiting aktiv
|
||||||
- [ ] Token-Rotation implementiert
|
- [x] Token-Rotation implementiert
|
||||||
|
|
||||||
## Phase 4: Admin & Monitoring (In Arbeit, 4-5 Tage)
|
## Phase 4: Admin & Monitoring (In Arbeit, 4-5 Tage)
|
||||||
### Ziele
|
### Ziele
|
||||||
@@ -99,14 +89,14 @@ Basierend auf aktueller Code-Analyse und Implementierung:
|
|||||||
### Implementierter Fortschritt
|
### Implementierter Fortschritt
|
||||||
- [x] **TenantResource erweitert**: credits, features, activeSubscription Attribute
|
- [x] **TenantResource erweitert**: credits, features, activeSubscription Attribute
|
||||||
- [x] **TenantModelTest**: 11 Unit-Tests für Beziehungen (events, photos, purchases), Attribute, Methoden
|
- [x] **TenantModelTest**: 11 Unit-Tests für Beziehungen (events, photos, purchases), Attribute, Methoden
|
||||||
- [ ] PurchaseHistoryResource, OAuthClientResource, Widgets, Policies
|
- [x] PurchaseHistoryResource, OAuthClientResource, Widgets, Policies
|
||||||
|
|
||||||
### Verbleibende Tasks
|
### Verbleibende Tasks
|
||||||
1. **Filament Resources erweitern (2 Tage)**
|
1. **Filament Resources erweitern (2 Tage)**
|
||||||
- TenantResource: subscription_tier, Actions (add_credits, suspend), RelationsManager
|
- TenantResource: subscription_tier, Actions (add_credits, suspend), RelationsManager *(Credits-Aktion fertig; subscription_tier-Actions noch offen)*
|
||||||
- PurchaseHistoryResource: CRUD, Filter, Export, Refund
|
- PurchaseHistoryResource: CRUD, Filter, Export, Refund *(CRUD & Export umgesetzt; Refund via UI noch offen)*
|
||||||
- OAuthClientResource: Client-Management
|
- OAuthClientResource: Client-Management *(implementiert)*
|
||||||
- TenantPolicy mit superadmin before()
|
- TenantPolicy mit superadmin before() *(implementiert)*
|
||||||
|
|
||||||
2. **Dashboard Widgets (1 Tag)**
|
2. **Dashboard Widgets (1 Tag)**
|
||||||
- RevenueChart, TopTenantsByRevenue, CreditAlerts
|
- RevenueChart, TopTenantsByRevenue, CreditAlerts
|
||||||
@@ -121,9 +111,9 @@ Basierend auf aktueller Code-Analyse und Implementierung:
|
|||||||
|
|
||||||
### Milestones
|
### Milestones
|
||||||
- [x] TenantResource basis erweitert
|
- [x] TenantResource basis erweitert
|
||||||
- [ ] PurchaseHistoryResource funktioniert
|
- [x] PurchaseHistoryResource funktioniert
|
||||||
- [ ] Widgets zeigen Stats
|
- [x] Widgets zeigen Stats
|
||||||
- [ ] Policies schützen SuperAdmin
|
- [x] Policies schützen SuperAdmin
|
||||||
- [ ] >80% Testabdeckung
|
- [ ] >80% Testabdeckung
|
||||||
|
|
||||||
## Gesamter Zeitplan
|
## Gesamter Zeitplan
|
||||||
@@ -133,7 +123,7 @@ Basierend auf aktueller Code-Analyse und Implementierung:
|
|||||||
| **1** | Foundation | ✅ Abgeschlossen |
|
| **1** | Foundation | ✅ Abgeschlossen |
|
||||||
| **1** | Core API | ✅ Abgeschlossen |
|
| **1** | Core API | ✅ Abgeschlossen |
|
||||||
| **2** | Business Logic | 40% â³ In Arbeit |
|
| **2** | Business Logic | 40% â³ In Arbeit |
|
||||||
| **2** | Admin & Monitoring | 20% ðŸâ€Â„ In Arbeit |
|
| **2** | Admin & Monitoring | 45% ✅ In Arbeit |
|
||||||
|
|
||||||
**Gesamtdauer:** **4-5 Tage** - Phase 2 vollständig abgeschlossen, Tests implementiert
|
**Gesamtdauer:** **4-5 Tage** - Phase 2 vollständig abgeschlossen, Tests implementiert
|
||||||
**Kritische Pfade:** Phase 3 (Business Logic) kann sofort starten
|
**Kritische Pfade:** Phase 3 (Business Logic) kann sofort starten
|
||||||
|
|||||||
15
docs/piwik-trackingcode.txt
Normal file
15
docs/piwik-trackingcode.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- Matomo -->
|
||||||
|
<script>
|
||||||
|
var _paq = window._paq = window._paq || [];
|
||||||
|
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||||
|
_paq.push(['trackPageView']);
|
||||||
|
_paq.push(['enableLinkTracking']);
|
||||||
|
(function() {
|
||||||
|
var u="//piwik.sebfoto.de/";
|
||||||
|
_paq.push(['setTrackerUrl', u+'matomo.php']);
|
||||||
|
_paq.push(['setSiteId', '6']);
|
||||||
|
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
||||||
|
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- End Matomo Code -->
|
||||||
@@ -25,6 +25,16 @@ Guest Polling (no WebSockets in v1)
|
|||||||
- GET `/events/{token}/photos?since=<ISO8601|cursor>` — incremental gallery refresh.
|
- GET `/events/{token}/photos?since=<ISO8601|cursor>` — incremental gallery refresh.
|
||||||
- Response: `{ data: Photo[], next_cursor?: string, latest_photo_at: ISO8601 }`.
|
- Response: `{ data: Photo[], next_cursor?: string, latest_photo_at: ISO8601 }`.
|
||||||
- Use `If-None-Match` or `If-Modified-Since` to return `304 Not Modified` when unchanged.
|
- Use `If-None-Match` or `If-Modified-Since` to return `304 Not Modified` when unchanged.
|
||||||
|
- Legacy slug-based guest endpoints have been removed; tokens are mandatory for public access.
|
||||||
|
|
||||||
Webhooks
|
Webhooks
|
||||||
- Payment provider events, media pipeline status, and deletion callbacks. All signed with shared secret per provider.
|
- Payment provider events, media pipeline status, and deletion callbacks. All signed with shared secret per provider.
|
||||||
|
- RevenueCat webhook: `POST /api/v1/webhooks/revenuecat` signed via `X-Signature` (HMAC SHA1/256). Dispatches `ProcessRevenueCatWebhook` to credit tenants and sync subscription expiry.
|
||||||
|
|
||||||
|
Public Gallery
|
||||||
|
- `GET /gallery/{token}`: returns event snapshot + branding colors; responds with `410` once the package gallery window expires.
|
||||||
|
- `GET /gallery/{token}/photos?cursor=&limit=`: cursor-based pagination of approved photos. Response shape `{ data: Photo[], next_cursor: string|null }`.
|
||||||
|
- `GET /gallery/{token}/photos/{photo}/download`: streams or redirects to an approved original. Returns `404` if the asset is gone.
|
||||||
|
|
||||||
|
Tenant Admin Downloads
|
||||||
|
- `GET /tenant/events/{event}/photos/archive`: authenticated ZIP export of all approved photos for an event. Returns `404` when none exist.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Core Features
|
|||||||
- Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe.
|
- Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe.
|
||||||
- Like (heart) with optimistic UI; share system sheet (URL to CDN variant).
|
- Like (heart) with optimistic UI; share system sheet (URL to CDN variant).
|
||||||
- Filters: emotion, featured, mine (local-only tag for items uploaded from this device).
|
- Filters: emotion, featured, mine (local-only tag for items uploaded from this device).
|
||||||
|
- Public share: host can hand out `https://app.domain/g/{token}`; guests see a themed, read-only gallery with per-photo downloads.
|
||||||
- Safety & abuse controls
|
- Safety & abuse controls
|
||||||
- Rate limits per device and IP; content-length checks; mime/type sniffing.
|
- Rate limits per device and IP; content-length checks; mime/type sniffing.
|
||||||
- Upload moderation state: pending → approved/hidden; show local status.
|
- Upload moderation state: pending → approved/hidden; show local status.
|
||||||
@@ -39,6 +40,7 @@ Core Features
|
|||||||
|
|
||||||
Screens
|
Screens
|
||||||
- Splash/Loading: event lookup + token validation; friendly skeleton.
|
- Splash/Loading: event lookup + token validation; friendly skeleton.
|
||||||
|
- Slug-based deep links are no longer accepted; guests must enter or scan a join token QR.
|
||||||
- Terms & PIN: legal links, optional PIN input; remember choice per event.
|
- Terms & PIN: legal links, optional PIN input; remember choice per event.
|
||||||
- Gallery: grid of approved photos; toolbar with filter, upload, settings.
|
- Gallery: grid of approved photos; toolbar with filter, upload, settings.
|
||||||
- Upload Picker: camera/library, selection preview, emotion/task tagging.
|
- Upload Picker: camera/library, selection preview, emotion/task tagging.
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ The Fotospiel platform supports multiple payment providers for package purchases
|
|||||||
- **Configuration**: Keys in `config/services.php` under `stripe`. Sandbox mode based on `APP_ENV`.
|
- **Configuration**: Keys in `config/services.php` under `stripe`. Sandbox mode based on `APP_ENV`.
|
||||||
- **Models**: `PackagePurchase` records all transactions with `provider_id` (Stripe PI ID), `status`, `metadata`.
|
- **Models**: `PackagePurchase` records all transactions with `provider_id` (Stripe PI ID), `status`, `metadata`.
|
||||||
- **Frontend**: PurchaseWizard.tsx handles client-side Stripe Elements for card input and confirmation.
|
- **Frontend**: PurchaseWizard.tsx handles client-side Stripe Elements for card input and confirmation.
|
||||||
|
- **Webhook Matrix**:
|
||||||
|
|
||||||
|
| Event | Purpose | Internal handler |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `payment_intent.succeeded` | Completes single-event package purchase | `StripeWebhookController@handlePaymentIntentSucceeded` |
|
||||||
|
| `payment_intent.payment_failed` | Logs failure, triggers recovery emails | `handlePaymentIntentFailed` |
|
||||||
|
| `invoice.paid` | Confirms subscription renewal, extends package | `handleInvoicePaid` |
|
||||||
|
| `invoice.payment_failed` | Flags tenant for follow-up, sends alerts | `handleInvoicePaymentFailed` |
|
||||||
|
| `customer.subscription.deleted` | Finalises cancellation/downgrade | `handleSubscriptionDeleted` |
|
||||||
|
|
||||||
## PayPal Integration
|
## PayPal Integration
|
||||||
- **SDK**: Migrated to PayPal Server SDK v1.0+ (`paypal/paypal-server-sdk`). Uses Builder pattern for requests and Controllers for API calls.
|
- **SDK**: Migrated to PayPal Server SDK v1.0+ (`paypal/paypal-server-sdk`). Uses Builder pattern for requests and Controllers for API calls.
|
||||||
@@ -17,6 +26,14 @@ The Fotospiel platform supports multiple payment providers for package purchases
|
|||||||
- **Differences**: One-time: Standard Order with payment_type ONE_TIME (default). Recurring: Order with StoredPaymentSource RECURRING to enable future charges without new approvals. plan_id stored in metadata for reference; no separate subscription ID from SDK.
|
- **Differences**: One-time: Standard Order with payment_type ONE_TIME (default). Recurring: Order with StoredPaymentSource RECURRING to enable future charges without new approvals. plan_id stored in metadata for reference; no separate subscription ID from SDK.
|
||||||
- **Client Setup**: OAuth2 Client Credentials flow. Builder: `PaypalServerSdkClientBuilder::init()->clientCredentialsAuthCredentials(ClientCredentialsAuthCredentialsBuilder::init(client_id, secret))->environment(Environment::SANDBOX/PRODUCTION)->build()`.
|
- **Client Setup**: OAuth2 Client Credentials flow. Builder: `PaypalServerSdkClientBuilder::init()->clientCredentialsAuthCredentials(ClientCredentialsAuthCredentialsBuilder::init(client_id, secret))->environment(Environment::SANDBOX/PRODUCTION)->build()`.
|
||||||
- **Webhooks**: `PayPalWebhookController@verify` handles events like `PAYMENT.CAPTURE.COMPLETED` (process initial/renewal purchase), `BILLING.SUBSCRIPTION.CANCELLED` or equivalent order events (deactivate package). Simplified signature verification (TODO: Implement `VerifyWebhookSignature`).
|
- **Webhooks**: `PayPalWebhookController@verify` handles events like `PAYMENT.CAPTURE.COMPLETED` (process initial/renewal purchase), `BILLING.SUBSCRIPTION.CANCELLED` or equivalent order events (deactivate package). Simplified signature verification (TODO: Implement `VerifyWebhookSignature`).
|
||||||
|
- **Webhook Matrix**:
|
||||||
|
|
||||||
|
| Event | Purpose | Internal handler |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PAYMENT.CAPTURE.COMPLETED` | Confirms one-time order, activates tenant package | `handleCapture` |
|
||||||
|
| `BILLING.SUBSCRIPTION.ACTIVATED`, `BILLING.SUBSCRIPTION.UPDATED` | Syncs reseller subscription status/expiry | `handleSubscription` |
|
||||||
|
| `BILLING.SUBSCRIPTION.CANCELLED`, `BILLING.SUBSCRIPTION.EXPIRED` | Marks package inactive and downgrades tenant | `handleSubscription` |
|
||||||
|
| `BILLING.SUBSCRIPTION.SUSPENDED` | Pauses package benefits pending review | `handleSubscription` |
|
||||||
- **Idempotency**: Check `provider_id` in `PackagePurchase` before creation. Transactions for DB safety.
|
- **Idempotency**: Check `provider_id` in `PackagePurchase` before creation. Transactions for DB safety.
|
||||||
- **Configuration**: Keys in `config/services.php` under `paypal`. Sandbox mode via `paypal.sandbox`.
|
- **Configuration**: Keys in `config/services.php` under `paypal`. Sandbox mode via `paypal.sandbox`.
|
||||||
- **Migration Notes**: Replaced old Checkout SDK (`PayPalCheckoutSdk`). Updated imports, requests (e.g., OrdersCreateRequest -> OrderRequestBuilder, Subscriptions -> StoredPaymentSource in Orders). Responses: Use `getStatusCode()` and `getResult()`. Tests mocked new structures. No breaking changes in auth or metadata handling; recurring flows now unified under Orders API.
|
- **Migration Notes**: Replaced old Checkout SDK (`PayPalCheckoutSdk`). Updated imports, requests (e.g., OrdersCreateRequest -> OrderRequestBuilder, Subscriptions -> StoredPaymentSource in Orders). Responses: Use `getStatusCode()` and `getResult()`. Tests mocked new structures. No breaking changes in auth or metadata handling; recurring flows now unified under Orders API.
|
||||||
|
|||||||
@@ -6,3 +6,20 @@
|
|||||||
- Logging: structured, no PII; add request/trace IDs; redact secrets.
|
- Logging: structured, no PII; add request/trace IDs; redact secrets.
|
||||||
- GDPR: retention settings per tenant; deletion workflows; legal pages managed via CMS-like resource.
|
- GDPR: retention settings per tenant; deletion workflows; legal pages managed via CMS-like resource.
|
||||||
- Rate limits: per-tenant, per-user, per-device; protect upload and admin mutations.
|
- Rate limits: per-tenant, per-user, per-device; protect upload and admin mutations.
|
||||||
|
|
||||||
|
## 2025 Hardening Priorities
|
||||||
|
|
||||||
|
- **Identity & OAuth** — *Owner: Backend Platform*
|
||||||
|
Track JWT key rotation via `oauth:rotate-keys`, roll out dual-key support (old/new KID overlap), surface refresh-token revocation tooling, and extend IP/device binding rules for long-lived sessions.
|
||||||
|
- **Guest Join Tokens** — *Owner: Guest Platform*
|
||||||
|
Hash stored join tokens, add anomaly metrics (usage spikes, stale tokens), and tighten gallery/photo rate limits with visibility in storage dashboards.
|
||||||
|
- **Public API Resilience** — *Owner: Core API*
|
||||||
|
Ensure gallery/download endpoints serve signed URLs, expand abuse throttles (token + IP), and document incident response runbooks in ops guides.
|
||||||
|
- **Media Pipeline & Storage** — *Owner: Media Services*
|
||||||
|
Introduce antivirus + EXIF scrubbing workers, stream uploads to disk to avoid buffering, and enforce checksum verification during hot→archive transfers with configurable alerts from `StorageHealthService`.
|
||||||
|
- **Payments & Webhooks** — *Owner: Billing*
|
||||||
|
Align legacy Stripe hooks with checkout sessions, add idempotency locks/signature expiry checks, and plug failed capture notifications into the credit ledger audit trail.
|
||||||
|
- **Frontend & CSP** — *Owner: Marketing Frontend*
|
||||||
|
Replace unsafe-inline allowances (Stripe/Matomo) with nonce or hashed CSP rules, gate analytics injection behind consent, and localise cookie-banner copy that discloses data sharing.
|
||||||
|
|
||||||
|
Progress updates belong in `docs/changes/` and roadmap status in `docs/implementation-roadmap.md`.
|
||||||
|
|||||||
29
docs/prp/11-public-gallery.md
Normal file
29
docs/prp/11-public-gallery.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 11 — Public Guest Gallery
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
- Provide a shareable, mobile-friendly gallery for guests who only need to view and download approved photos.
|
||||||
|
- Respect existing join-token security and automatically disable access once a package’s gallery duration expires.
|
||||||
|
|
||||||
|
Access Model
|
||||||
|
- URL pattern: `https://<app-domain>/g/{joinToken}`. Tokens are the same join tokens tenants already issue; revoking or expiring a token immediately locks the gallery.
|
||||||
|
- Tokens expire when the associated event package’s `gallery_expires_at` passes; guests receive an explanatory message (HTTP `410`).
|
||||||
|
- Only *approved* photos appear; pending/rejected items remain hidden.
|
||||||
|
|
||||||
|
Client Experience
|
||||||
|
- Responsive grid with lazy-loaded thumbnails (IntersectionObserver) and infinite scroll/pagination.
|
||||||
|
- Event branding colours (primary, secondary, background) are applied via CSS custom properties fetched from the API.
|
||||||
|
- Fullscreen lightbox shows creation timestamp + guest label when available and exposes a single-photo download button (streams the original asset).
|
||||||
|
|
||||||
|
API Touchpoints (see 03 — API Contract for details)
|
||||||
|
- `GET /api/v1/gallery/{token}` → event snapshot + branding.
|
||||||
|
- `GET /api/v1/gallery/{token}/photos` → cursor-based pagination of approved photos.
|
||||||
|
- `GET /api/v1/gallery/{token}/photos/{photo}/download` → single-photo download/redirect.
|
||||||
|
|
||||||
|
Tenant Admin Support
|
||||||
|
- Filament action “Download all photos” (Event resource) queues a server-side ZIP export via `GET /tenant/events/{event}/photos/archive` for authenticated tenants.
|
||||||
|
- Only approved photos are included; failed assets are skipped with logging.
|
||||||
|
|
||||||
|
Future Enhancements
|
||||||
|
- Background job + notification for large ZIP exports (current implementation streams synchronously).
|
||||||
|
- Optional passcode/PIN layered on top of join tokens for sensitive events.
|
||||||
|
- Aggregate analytics (views/downloads per photo) presented in Tenant Admin dashboards.
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
- **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds.
|
- **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds.
|
||||||
- **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA.
|
- **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA.
|
||||||
- **Webhooks** (Stripe, PayPal) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths.
|
- **Webhooks** (Stripe, PayPal) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths.
|
||||||
|
- **Feature Flag**: `config/checkout.php` exposes `CHECKOUT_WIZARD_ENABLED` and `CHECKOUT_WIZARD_FLAG` so the SPA flow can be toggled or gradual-rolled out during launch.
|
||||||
|
- **Operational**: Rotate JWT signing keys with `php artisan oauth:rotate-keys` (updates key folder per KID; remember to bump `OAUTH_JWT_KID`).
|
||||||
|
|
||||||
## Payment State Machine
|
## Payment State Machine
|
||||||
State constants live on `CheckoutSession` (`status` column, enum):
|
State constants live on `CheckoutSession` (`status` column, enum):
|
||||||
@@ -129,6 +131,5 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
|
|||||||
|
|
||||||
## Open Questions / Follow-Ups
|
## Open Questions / Follow-Ups
|
||||||
- Map package records to Stripe price ids and PayPal plan ids (store on `packages` table or config?).
|
- Map package records to Stripe price ids and PayPal plan ids (store on `packages` table or config?).
|
||||||
- Determine how Google sign-in enrichment (comfort login) feeds the checkout session once implemented.
|
|
||||||
- Confirm legal copy updates for new checkout experience before GA.
|
- Confirm legal copy updates for new checkout experience before GA.
|
||||||
- Align email templates (welcome, receipt) with new assignment service outputs.
|
- Align email templates (welcome, receipt) with new assignment service outputs.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ Ziel: Vollständige Migration zu Inertia.js für SPA-ähnliche Konsistenz, mit e
|
|||||||
- **Auth-Integration**: usePage().props.auth für CTAs (z.B. Login-Button im Header).
|
- **Auth-Integration**: usePage().props.auth für CTAs (z.B. Login-Button im Header).
|
||||||
- **Responsive Design**: Tailwind: block md:hidden für Mobile-Carousel in Packages.
|
- **Responsive Design**: Tailwind: block md:hidden für Mobile-Carousel in Packages.
|
||||||
- **Fonts & Assets**: Vite-Plugin für Fonts/SVGs; preload in Layout.
|
- **Fonts & Assets**: Vite-Plugin für Fonts/SVGs; preload in Layout.
|
||||||
|
- **Analytics (Matomo)**: Aktivierung via `.env` (`MATOMO_ENABLED=true`, `MATOMO_URL`, `MATOMO_SITE_ID`). `AppServiceProvider` teilt die Konfiguration als `analytics.matomo`; `MarketingLayout` rendert `MatomoTracker`, der das Snippet aus `/docs/piwik-trackingcode.txt` nur bei erteilter Analyse-Zustimmung lädt, `disableCookies` setzt und bei jedem Inertia-Navigationsevent `trackPageView` sendet. Ein lokalisierter Consent-Banner (DE/EN) übernimmt die DSGVO-konforme Einwilligung und ist über den Footer erneut erreichbar.
|
||||||
- **Tests**: E2E mit Playwright (z.B. navigate to /packages, check header/footer presence).
|
- **Tests**: E2E mit Playwright (z.B. navigate to /packages, check header/footer presence).
|
||||||
|
|
||||||
### 5. Diagramm: Layout-Struktur
|
### 5. Diagramm: Layout-Struktur
|
||||||
|
|||||||
28
docs/prp/public-entrypoints.md
Normal file
28
docs/prp/public-entrypoints.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Public Entry Points
|
||||||
|
|
||||||
|
This overview lists every user-facing URL surface, grouped by persona, and notes authentication/expiry rules.
|
||||||
|
|
||||||
|
## Marketing Site
|
||||||
|
- `/` — marketing landing page.
|
||||||
|
- `/packages` — package overview.
|
||||||
|
- `/checkout/{package}` — checkout wizard (requires logged-in tenant or email login within flow).
|
||||||
|
- `/blog`, `/contact`, `/impressum`, `/datenschutz`, `/agb` — legal and marketing content.
|
||||||
|
|
||||||
|
## Tenant Admin
|
||||||
|
- `/event-admin/*` — protected Filament SPA (requires tenant credentials).
|
||||||
|
- `/tenant/events/{event}/photos/archive` — authenticated ZIP export for approved photos (tenant ownership enforced).
|
||||||
|
|
||||||
|
## Guest PWA (event-bound)
|
||||||
|
- `/event` — landing for new guests (code entry / QR).
|
||||||
|
- `/e/{token}` — full guest experience (home, tasks, gallery, upload) gated by join token; token expiry revokes access.
|
||||||
|
- `/g/{token}` — read-only public gallery (new). Shows approved photos themed by event branding; downloads allowed while token valid and gallery duration active.
|
||||||
|
- `/setup/{token}` — onboarding/profile setup for guests.
|
||||||
|
|
||||||
|
## API (selected public endpoints)
|
||||||
|
- `/api/v1/events/{token}` — event metadata for guest PWA.
|
||||||
|
- `/api/v1/events/{token}/photos` — guest gallery polling (legacy PWA).
|
||||||
|
- `/api/v1/gallery/{token}` — public gallery metadata (new).
|
||||||
|
- `/api/v1/gallery/{token}/photos` — public gallery pagination (new).
|
||||||
|
- `/api/v1/gallery/{token}/photos/{photo}/download` — single photo download (new).
|
||||||
|
|
||||||
|
All other `/api/v1/*` routes require authenticated tenant or super-admin access as documented in `docs/prp/03-api.md`.
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
# Detaillierte PRP für Tenant Admin App (Capacitor + Framework7)
|
# Detaillierte PRP für Tenant Admin App (Capacitor + Framework7)
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
- **Aktiv**: Erste Version (2025-09-13)
|
- **Aktualisiert**: 2025-10-17 (Onboarding Fusion & QR Revamp)
|
||||||
- **Version**: 1.0.0
|
- **Version**: 1.2.0
|
||||||
- **Autor**: Sonoma (AI Architect)
|
- **Autor**: Core Platform Team (Codex)
|
||||||
- **Supersedes**: docs/prp/06-tenant-admin-pwa.md (erweitert und detailliert)
|
- **Supersedes**: docs/prp/06-tenant-admin-pwa.md (legacy Framework7 reference)
|
||||||
|
|
||||||
## Überblick
|
## Überblick
|
||||||
Diese detaillierte Product Requirement Plan (PRP) beschreibt die Spezifikationen für die Tenant Admin App. Die App ist eine store-ready mobile Anwendung, die mit Capacitor für iOS und Trusted Web Activity (TWA) für Android gepackt wird. Die UI basiert auf Framework7 für ein natives Mobile-Erlebnis. Die App ermöglicht Tenant-Admins (z.B. Event-Organisatoren) die vollständige Verwaltung ihrer Events, Galerien, Mitglieder, Einstellungen und Käufe über eine API-first Backend-Integration.
|
Diese detaillierte Product Requirement Plan (PRP) beschreibt die Spezifikationen für die Tenant Admin App. Die App ist eine store-ready mobile Anwendung, die mit Capacitor für iOS und Trusted Web Activity (TWA) für Android gepackt wird. Die UI basiert auf Framework7 für ein natives Mobile-Erlebnis. Die App ermöglicht Tenant-Admins (z.B. Event-Organisatoren) die vollständige Verwaltung ihrer Events, Galerien, Mitglieder, Einstellungen und Käufe über eine API-first Backend-Integration.
|
||||||
|
|
||||||
Die App ersetzt das frühere Filament-basierte Tenant-Panel und fokussiert auf Mobile-First-UX mit Offline-Fähigkeiten, Push-Notifications und sicherer Authentifizierung. Sie respektiert das Multi-Tenancy-Modell und GDPR-Anforderungen.
|
Die App ersetzt das frühere Filament-basierte Tenant-Panel und fokussiert auf eine Mobile-First-UX mit Offline-Fähigkeiten, Push-Notifications und sicherer Authentifizierung. Sie respektiert das Multi-Tenancy-Modell und GDPR-Anforderungen. Seit Oktober 2025 wird das UI in React 19 + Vite + Tailwind/shadcn/ui umgesetzt; die alte Framework7-Schicht bleibt nur als historische Referenz erhalten.
|
||||||
|
|
||||||
|
## Aktuelle Highlights (Q4 2025)
|
||||||
|
- **Geführtes Onboarding**: `/event-admin/welcome/*` orchestriert den Welcome Flow (Hero → How-It-Works → Paketwahl → Zusammenfassung → Event Setup). Guarding erfolgt über `onboarding_completed_at`.
|
||||||
|
- **Direkter Checkout**: Stripe & PayPal sind in die Paketwahl des Welcome Flows eingebettet; Fortschritt wird im Onboarding-Context persistiert.
|
||||||
|
- **Filament Wizard**: Für Super-Admins existiert ein paralleler QR/Join-Token-Wizard in Filament (Token-Generierung, Layout-Downloads, Rotation).
|
||||||
|
- **Join Tokens only**: Gäste erhalten ausschließlich join-token-basierte Links/QRs; slug-basierte URLs wurden deaktiviert. QR-Drucklayouts liegen unter `resources/views/pdf/join-tokens/*`.
|
||||||
|
- **OAuth Alignment**: `VITE_OAUTH_CLIENT_ID` + `/event-admin/auth/callback` werden seedingseitig synchron gehalten; siehe `docs/prp/tenant-app-specs/api-usage.md`.
|
||||||
|
|
||||||
## Kernziele
|
## Kernziele
|
||||||
- **Deliverables**: Voll funktionsfähige App mit CRUD-Operationen für Tenant-Ressourcen (Events, Photos, Tasks, etc.).
|
- **Deliverables**: Voll funktionsfähige App mit CRUD-Operationen für Tenant-Ressourcen (Events, Photos, Tasks, etc.).
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
|
|||||||
- **Redirect URI**: Standardmaessig `${origin}/event-admin/auth/callback` (per Vite-Env anpassbar)
|
- **Redirect URI**: Standardmaessig `${origin}/event-admin/auth/callback` (per Vite-Env anpassbar)
|
||||||
- **Headers**: `Authorization: Bearer {access_token}`
|
- **Headers**: `Authorization: Bearer {access_token}`
|
||||||
- **Response**: `{ id, email, tenant_id, role, name }`
|
- **Response**: `{ id, email, tenant_id, role, name }`
|
||||||
|
- **Hinweis**: `client_id` entspricht `VITE_OAUTH_CLIENT_ID`; Seeder `OAuthClientSeeder` synchronisiert Frontend und Backend.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
@@ -260,6 +261,7 @@ curl -H "Authorization: Bearer {token}" \
|
|||||||
- **VITE_API_URL**: Backend-API-URL (Pflicht)
|
- **VITE_API_URL**: Backend-API-URL (Pflicht)
|
||||||
- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht, muss mit `config/services.php` übereinstimmen – der Seeder legt damit den Client in `oauth_clients` an)
|
- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht, muss mit `config/services.php` übereinstimmen – der Seeder legt damit den Client in `oauth_clients` an)
|
||||||
- **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat)
|
- **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat)
|
||||||
|
- **REVENUECAT_WEBHOOK_SECRET / REVENUECAT_PRODUCT_MAPPINGS / REVENUECAT_APP_USER_PREFIX / REVENUECAT_WEBHOOK_QUEUE**: Backend-Konfiguration für RevenueCat-Webhooks, siehe `config/services.php`.
|
||||||
|
|
||||||
### Build & Deploy
|
### Build & Deploy
|
||||||
1. **Development**: `npm run dev`
|
1. **Development**: `npm run dev`
|
||||||
@@ -272,4 +274,3 @@ curl -H "Authorization: Bearer {token}" \
|
|||||||
|
|
||||||
Für weitere Details siehe die spezifischen Dokumentationsdateien.
|
Für weitere Details siehe die spezifischen Dokumentationsdateien.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
|
|||||||
|
|
||||||
### Onboarding Journey
|
### Onboarding Journey
|
||||||
- Routen `/event-admin/welcome/*` bilden den Flow.
|
- Routen `/event-admin/welcome/*` bilden den Flow.
|
||||||
|
- Filament stellt einen korrespondierenden Onboarding-Wizard (QR/Join-Token, Layout-Download) bereit; Abschluss setzt `onboarding_completed_at` serverseitig.
|
||||||
- `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`).
|
- `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`).
|
||||||
- Paketwahl nutzt `GET /tenant/packages`; Stripe/PayPal-Fallbacks informieren bei fehlender Konfiguration.
|
- Paketwahl nutzt `GET /tenant/packages`; Stripe/PayPal-Fallbacks informieren bei fehlender Konfiguration.
|
||||||
- Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde.
|
- Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde.
|
||||||
|
|
||||||
### Event Lifecycle
|
### Event Lifecycle
|
||||||
- Erstellung prüft Paketverfügbarkeit; generiert Join-Token.
|
- Erstellung prüft Paketverfügbarkeit; generiert Join-Token (EventJoinToken-Service).
|
||||||
|
- QR-Layouts und Token-Rotation erfolgen über `/event-admin/welcome` bzw. das Filament-Panel; slug-basierte QR-Links wurden deaktiviert.
|
||||||
- Bearbeiten erlaubt Statuswechsel, Aufgaben, Emotions, Join-Token-Verwaltung.
|
- Bearbeiten erlaubt Statuswechsel, Aufgaben, Emotions, Join-Token-Verwaltung.
|
||||||
- Veröffentlichen schaltet Guest-PWA frei; Archivieren respektiert Retention-Policy.
|
- Veröffentlichen schaltet Guest-PWA frei; Archivieren respektiert Retention-Policy.
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ Replace slug-based guest access with opaque, revocable join tokens and provide p
|
|||||||
- [x] Build “QR & Invites” management UI (list tokens, usage stats, rotate/revoke).
|
- [x] Build “QR & Invites” management UI (list tokens, usage stats, rotate/revoke).
|
||||||
- [x] Hook Filament action + PWA screens to call new token endpoints.
|
- [x] Hook Filament action + PWA screens to call new token endpoints.
|
||||||
- [x] Generate five print-ready layouts (PDF/SVG) per token with download options.
|
- [x] Generate five print-ready layouts (PDF/SVG) per token with download options.
|
||||||
- [ ] Deprecate slug-based QR view; link tenants to new flow.
|
- [x] Deprecate slug-based QR view; link tenants to new flow.
|
||||||
|
|
||||||
## Phase 4 – Migration & Cleanup
|
## Phase 4 – Migration & Cleanup
|
||||||
- [ ] Remove slug parameters from public endpoints once traffic confirms token usage.
|
- [x] Remove slug parameters from public endpoints (legacy slug URLs now return invalid_token).
|
||||||
- [ ] Update documentation (PRP, onboarding guides, runbooks) to reflect token process.
|
- [x] Update documentation (PRP, onboarding guides, runbooks) to reflect token process.
|
||||||
- [ ] Add feature/integration tests covering expiry, rotation, and guest flows.
|
- [x] Add feature/integration tests covering expiry, rotation, and guest flows.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
- Decide on default token lifetime and rotation cadence.
|
- Decide on default token lifetime and rotation cadence.
|
||||||
|
|||||||
42
docs/todo/security-hardening-epic.md
Normal file
42
docs/todo/security-hardening-epic.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Security Hardening Epic (Q4 2025)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Raise the baseline security posture across guest APIs, checkout, media storage, and identity flows so the platform can scale multi-tenant traffic with auditable, revocable access.
|
||||||
|
|
||||||
|
## Workstreams
|
||||||
|
|
||||||
|
1. **Identity & OAuth (Backend Platform)**
|
||||||
|
- Dual-key rollout for JWT signing with rotation runbook and monitoring.
|
||||||
|
- Refresh-token revocation tooling (per device/IP) and anomaly alerts.
|
||||||
|
- Device fingerprint/subnet allowances documented and configurable.
|
||||||
|
|
||||||
|
2. **Guest Join Tokens (Guest Platform)**
|
||||||
|
- Store hashed tokens with irreversible lookups; migrate legacy data.
|
||||||
|
- Add per-token usage analytics, alerting on spikes or expiry churn.
|
||||||
|
- Extend gallery/photo rate limits (token + IP) and surface breach telemetry in storage dashboards.
|
||||||
|
|
||||||
|
3. **Public API Resilience (Core API)**
|
||||||
|
- Serve signed asset URLs instead of raw storage paths; expire appropriately.
|
||||||
|
- Document incident response runbooks and playbooks for abuse mitigation.
|
||||||
|
- Add synthetic monitors for `/api/v1/gallery/*` and upload endpoints.
|
||||||
|
|
||||||
|
4. **Media Pipeline & Storage (Media Services)**
|
||||||
|
- Integrate antivirus/EXIF scrubbers and streaming upload paths to avoid buffering.
|
||||||
|
- Verify checksum integrity on hot → archive transfers with alert thresholds.
|
||||||
|
- Surface storage target health (capacity, latency) in Super Admin dashboards.
|
||||||
|
|
||||||
|
5. **Payments & Webhooks (Billing)**
|
||||||
|
- Link Stripe/PayPal webhooks to checkout sessions with idempotency locks.
|
||||||
|
- Add signature freshness validation + retry policies for provider outages.
|
||||||
|
- Pipe failed capture events into credit ledger audits and operator alerts.
|
||||||
|
|
||||||
|
6. **Frontend & CSP (Marketing Frontend)**
|
||||||
|
- Replace `unsafe-inline` allowances with nonce/hash policies for Stripe + Matomo.
|
||||||
|
- Gate analytics script injection behind consent with localised disclosures.
|
||||||
|
- Broaden cookie banner layout to surface GDPR/legal copy clearly.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
- Updated docs (`docs/prp/09-security-compliance.md`, runbooks) with ownership & SLAs.
|
||||||
|
- Feature flags / configuration toggles for rollouts (JWT KID, signed URLs, CSP nonces).
|
||||||
|
- Monitoring dashboards + alerting coverage per workstream.
|
||||||
|
- Integration and Playwright coverage validating the hardened flows.
|
||||||
@@ -46,7 +46,7 @@ Owner: Codex (handoff)
|
|||||||
- [x] Rebrand the Filament tenant panel away from “Admin” by adjusting `AdminPanelProvider` (brand name, home URL, navigation visibility) and registering a new onboarding home page.
|
- [x] Rebrand the Filament tenant panel away from “Admin” by adjusting `AdminPanelProvider` (brand name, home URL, navigation visibility) and registering a new onboarding home page.
|
||||||
- [x] Build the Filament onboarding wizard (welcome → task package selection → event name → color palette → QR layout) with persisted progress on the tenant record and guards that hide legacy resource menus until completion.
|
- [x] Build the Filament onboarding wizard (welcome → task package selection → event name → color palette → QR layout) with persisted progress on the tenant record and guards that hide legacy resource menus until completion.
|
||||||
- [x] Expose QR invite generation in Filament via a dedicated page/component that reuses the join-token flow from `EventDetailPage.tsx`, ensuring tokens stay in sync between PWA and Filament.
|
- [x] Expose QR invite generation in Filament via a dedicated page/component that reuses the join-token flow from `EventDetailPage.tsx`, ensuring tokens stay in sync between PWA and Filament.
|
||||||
- [ ] Update PRP/docs to cover the new welcome flow, OAuth alignment, Filament onboarding, and QR tooling; add regression notes + tests for the adjusted routes.
|
- [x] Update PRP/docs to cover die neue Welcome Journey, OAuth-Ausrichtung, Filament-Onboarding und QR-Tooling; Regression Notes + Tests dokumentiert.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
6
lang/de/checkout.php
Normal file
6
lang/de/checkout.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'google_error_fallback' => 'Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.',
|
||||||
|
'google_missing_email' => 'Wir konnten deine Google-E-Mail-Adresse nicht abrufen.',
|
||||||
|
];
|
||||||
6
lang/en/checkout.php
Normal file
6
lang/en/checkout.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'google_error_fallback' => 'We could not complete the Google login. Please try again.',
|
||||||
|
'google_missing_email' => 'We could not retrieve your Google email address.',
|
||||||
|
];
|
||||||
@@ -50,5 +50,33 @@
|
|||||||
"email_verify_title": "E-Mail verifizieren",
|
"email_verify_title": "E-Mail verifizieren",
|
||||||
"email_verify_desc": "Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Verifizierungslink.",
|
"email_verify_desc": "Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Verifizierungslink.",
|
||||||
"language_select": "Sprache wählen"
|
"language_select": "Sprache wählen"
|
||||||
|
},
|
||||||
|
"consent": {
|
||||||
|
"banner": {
|
||||||
|
"title": "Wir respektieren deine Privatsphäre",
|
||||||
|
"body": "Wir verwenden Cookies für notwendige Funktionen und optionale Analysen zur Verbesserung. Du entscheidest, was erlaubt ist.",
|
||||||
|
"accept": "Alle akzeptieren",
|
||||||
|
"reject": "Nur notwendige",
|
||||||
|
"customize": "Individuell auswählen"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"title": "Privatsphäre-Einstellungen",
|
||||||
|
"description": "Lege fest, wie wir Cookies und ähnliche Technologien nutzen. Du kannst deine Auswahl jederzeit ändern.",
|
||||||
|
"functional": "Notwendige Cookies",
|
||||||
|
"functional_desc": "Wichtig für sicheren Login, Spracheinstellungen und die Grundfunktionen der Website.",
|
||||||
|
"analytics": "Analyse",
|
||||||
|
"analytics_desc": "Hilft uns mit Matomo, die Nutzung zu verstehen und Angebote zu verbessern.",
|
||||||
|
"required": "Pflicht",
|
||||||
|
"save": "Auswahl speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"accept_all": "Alle akzeptieren",
|
||||||
|
"reject_all": "Nur notwendige"
|
||||||
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"banner_label": "Cookie-Hinweis"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"manage_link": "Cookie-Einstellungen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"hero_title": "Fotospiel",
|
"hero_title": "Fotospiel",
|
||||||
"hero_description": "Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.",
|
"hero_description": "Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.",
|
||||||
"cta_explore": "Pakete entdecken",
|
"cta_explore": "Pakete entdecken",
|
||||||
|
"cta_explore_highlight": "Jetzt loslegen",
|
||||||
"hero_image_alt": "Event-Fotos mit QR-Code",
|
"hero_image_alt": "Event-Fotos mit QR-Code",
|
||||||
"how_title": "So funktioniert es",
|
"how_title": "So funktioniert es",
|
||||||
"step1_title": "Paket wählen",
|
"step1_title": "Paket wählen",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"hero_title": "Entdecken Sie unsere flexiblen Packages",
|
"hero_title": "Entdecken Sie unsere flexiblen Packages",
|
||||||
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
||||||
"cta_explore": "Pakete entdecken",
|
"cta_explore": "Pakete entdecken",
|
||||||
|
"cta_explore_highlight": "Lieblingspaket sichern",
|
||||||
"tab_endcustomer": "Endkunden",
|
"tab_endcustomer": "Endkunden",
|
||||||
"tab_reseller": "Reseller & Agenturen",
|
"tab_reseller": "Reseller & Agenturen",
|
||||||
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
||||||
@@ -115,8 +117,16 @@
|
|||||||
"feature_overview": "Feature-Überblick",
|
"feature_overview": "Feature-Überblick",
|
||||||
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.",
|
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.",
|
||||||
"features_label": "Features",
|
"features_label": "Features",
|
||||||
|
"feature_highlights": "Feature-Highlights",
|
||||||
|
"more_details_tab": "Mehr Details",
|
||||||
|
"quick_facts": "Schnelle Fakten",
|
||||||
|
"quick_facts_hint": "Der schnelle Überblick über die wichtigsten Kennzahlen.",
|
||||||
|
"more_details_link": "Noch mehr Details anzeigen",
|
||||||
|
"badge_deep_dive": "Deep Dive",
|
||||||
"breakdown_label": "Leistungsübersicht",
|
"breakdown_label": "Leistungsübersicht",
|
||||||
|
"breakdown_label_hint": "Erfahre, wie das Paket im Detail aufgebaut ist.",
|
||||||
"limits_label": "Limits & Kapazitäten",
|
"limits_label": "Limits & Kapazitäten",
|
||||||
|
"limits_label_hint": "Alle Kennzahlen auf einen Blick – ideal für Planung und Freigaben.",
|
||||||
"for_endcustomers": "Für Endkunden",
|
"for_endcustomers": "Für Endkunden",
|
||||||
"for_resellers": "Für Reseller",
|
"for_resellers": "Für Reseller",
|
||||||
"details_show": "Details anzeigen",
|
"details_show": "Details anzeigen",
|
||||||
@@ -378,7 +388,12 @@
|
|||||||
"next_to_payment": "Weiter zur Zahlung",
|
"next_to_payment": "Weiter zur Zahlung",
|
||||||
"switch_to_register": "Registrieren",
|
"switch_to_register": "Registrieren",
|
||||||
"switch_to_login": "Anmelden",
|
"switch_to_login": "Anmelden",
|
||||||
"google_coming_soon": "Google-Login kommt bald im Comfort-Delta."
|
"continue_with_google": "Mit Google fortfahren",
|
||||||
|
"google_success_toast": "Mit Google angemeldet.",
|
||||||
|
"google_error_title": "Google-Anmeldung fehlgeschlagen",
|
||||||
|
"google_missing_package": "Bitte wähle zuerst ein Paket aus, bevor du Google Login nutzt.",
|
||||||
|
"google_missing_email": "Wir konnten deine Google-E-Mail-Adresse nicht abrufen.",
|
||||||
|
"google_error_fallback": "Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut."
|
||||||
},
|
},
|
||||||
"payment_step": {
|
"payment_step": {
|
||||||
"title": "Zahlung",
|
"title": "Zahlung",
|
||||||
@@ -389,6 +404,7 @@
|
|||||||
"activate_package": "Paket aktivieren",
|
"activate_package": "Paket aktivieren",
|
||||||
"loading_payment": "Zahlungsdaten werden geladen...",
|
"loading_payment": "Zahlungsdaten werden geladen...",
|
||||||
"secure_payment_desc": "Sichere Zahlung mit Kreditkarte, Debitkarte oder SEPA-Lastschrift.",
|
"secure_payment_desc": "Sichere Zahlung mit Kreditkarte, Debitkarte oder SEPA-Lastschrift.",
|
||||||
|
"secure_paypal_desc": "Sichere Zahlung mit PayPal.",
|
||||||
"payment_failed": "Zahlung fehlgeschlagen. ",
|
"payment_failed": "Zahlung fehlgeschlagen. ",
|
||||||
"error_card": "Kartenfehler aufgetreten.",
|
"error_card": "Kartenfehler aufgetreten.",
|
||||||
"error_validation": "Eingabedaten sind ungültig.",
|
"error_validation": "Eingabedaten sind ungültig.",
|
||||||
@@ -404,7 +420,23 @@
|
|||||||
"pay_now": "Jetzt bezahlen (€{price})",
|
"pay_now": "Jetzt bezahlen (€{price})",
|
||||||
"stripe_not_loaded": "Stripe ist nicht initialisiert. Bitte Seite neu laden.",
|
"stripe_not_loaded": "Stripe ist nicht initialisiert. Bitte Seite neu laden.",
|
||||||
"network_error": "Netzwerkfehler beim Laden der Zahlungsdaten",
|
"network_error": "Netzwerkfehler beim Laden der Zahlungsdaten",
|
||||||
"payment_intent_error": "Fehler beim Laden der Zahlungsdaten"
|
"payment_intent_error": "Fehler beim Laden der Zahlungsdaten",
|
||||||
|
"paypal_order_error": "PayPal-Bestellung konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||||
|
"paypal_capture_error": "PayPal-Abschluss fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"paypal_error": "PayPal meldete einen Fehler.",
|
||||||
|
"paypal_cancelled": "Sie haben die PayPal-Zahlung abgebrochen.",
|
||||||
|
"paypal_missing_plan": "Für dieses Paket fehlt die PayPal-Plan-Konfiguration. Bitte wählen Sie eine andere Zahlungsmethode.",
|
||||||
|
"auth_required": "Bitte melde dich an, um mit der Zahlung fortzufahren.",
|
||||||
|
"status_loading": "Zahlungsvorbereitung läuft…",
|
||||||
|
"status_ready": "Zahlungsformular bereit. Bitte gib deine Daten ein.",
|
||||||
|
"status_processing": "Zahlung mit {{provider}} wird verarbeitet…",
|
||||||
|
"status_success": "Zahlung bestätigt. Wir schließen den Kauf ab…",
|
||||||
|
"status_info_title": "Zahlungsstatus",
|
||||||
|
"status_error_title": "Zahlung fehlgeschlagen",
|
||||||
|
"status_success_title": "Zahlung abgeschlossen",
|
||||||
|
"status_retry": "Erneut versuchen",
|
||||||
|
"method_stripe": "Kreditkarte (Stripe)",
|
||||||
|
"method_paypal": "PayPal"
|
||||||
},
|
},
|
||||||
"confirmation_step": {
|
"confirmation_step": {
|
||||||
"title": "Bestätigung",
|
"title": "Bestätigung",
|
||||||
@@ -427,7 +459,12 @@
|
|||||||
"already_logged_in": "Sie sind bereits als {email} eingeloggt.",
|
"already_logged_in": "Sie sind bereits als {email} eingeloggt.",
|
||||||
"switch_to_register": "Registrieren",
|
"switch_to_register": "Registrieren",
|
||||||
"switch_to_login": "Anmelden",
|
"switch_to_login": "Anmelden",
|
||||||
"google_coming_soon": "Google-Login kommt bald im Comfort-Delta."
|
"continue_with_google": "Mit Google fortfahren",
|
||||||
|
"google_success_toast": "Mit Google angemeldet.",
|
||||||
|
"google_error_title": "Google-Anmeldung fehlgeschlagen",
|
||||||
|
"google_missing_package": "Bitte wähle zuerst ein Paket aus, bevor du Google Login nutzt.",
|
||||||
|
"google_missing_email": "Wir konnten deine Google-E-Mail-Adresse nicht abrufen.",
|
||||||
|
"google_error_fallback": "Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,5 +50,33 @@
|
|||||||
"email_verify_title": "Verify Email",
|
"email_verify_title": "Verify Email",
|
||||||
"email_verify_desc": "Please check your email and click the verification link.",
|
"email_verify_desc": "Please check your email and click the verification link.",
|
||||||
"language_select": "Language Select"
|
"language_select": "Language Select"
|
||||||
|
},
|
||||||
|
"consent": {
|
||||||
|
"banner": {
|
||||||
|
"title": "We respect your privacy",
|
||||||
|
"body": "We use cookies for essential features and optional analytics to improve our service. Choose what you allow.",
|
||||||
|
"accept": "Accept all",
|
||||||
|
"reject": "Reject non-essential",
|
||||||
|
"customize": "Customize"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"title": "Privacy settings",
|
||||||
|
"description": "Adjust how we use cookies and similar technologies. You can update your choice at any time.",
|
||||||
|
"functional": "Necessary cookies",
|
||||||
|
"functional_desc": "Required to deliver secure login, remember your language, and keep the site running.",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"analytics_desc": "Helps us understand usage with Matomo so we can improve the experience.",
|
||||||
|
"required": "Required",
|
||||||
|
"save": "Save selection",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"accept_all": "Accept all",
|
||||||
|
"reject_all": "Reject all"
|
||||||
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"banner_label": "Cookie consent banner"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"manage_link": "Cookie settings"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"hero_title": "Fotospiel",
|
"hero_title": "Fotospiel",
|
||||||
"hero_description": "Collect guest photos for events with QR codes. Our secure PWA platform for guests and organizers – simple, mobile, and privacy-compliant. Better than competitors, loved by thousands.",
|
"hero_description": "Collect guest photos for events with QR codes. Our secure PWA platform for guests and organizers – simple, mobile, and privacy-compliant. Better than competitors, loved by thousands.",
|
||||||
"cta_explore": "Discover Packages",
|
"cta_explore": "Discover Packages",
|
||||||
|
"cta_explore_highlight": "Get started now",
|
||||||
"hero_image_alt": "Event photos with QR code",
|
"hero_image_alt": "Event photos with QR code",
|
||||||
"how_title": "How it works",
|
"how_title": "How it works",
|
||||||
"step1_title": "Choose Package",
|
"step1_title": "Choose Package",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"hero_title": "Discover our flexible Packages",
|
"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.",
|
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure, and scalable.",
|
||||||
"cta_explore": "Discover Packages",
|
"cta_explore": "Discover Packages",
|
||||||
|
"cta_explore_highlight": "Explore top packages",
|
||||||
"tab_endcustomer": "End Customers",
|
"tab_endcustomer": "End Customers",
|
||||||
"tab_reseller": "Resellers & Agencies",
|
"tab_reseller": "Resellers & Agencies",
|
||||||
"section_endcustomer": "Packages for End Customers (One-time purchase per event)",
|
"section_endcustomer": "Packages for End Customers (One-time purchase per event)",
|
||||||
@@ -105,8 +107,16 @@
|
|||||||
"feature_overview": "Feature overview",
|
"feature_overview": "Feature overview",
|
||||||
"order_hint": "Launch instantly – secure Stripe or PayPal checkout, no hidden fees.",
|
"order_hint": "Launch instantly – secure Stripe or PayPal checkout, no hidden fees.",
|
||||||
"features_label": "Features",
|
"features_label": "Features",
|
||||||
|
"feature_highlights": "Feature Highlights",
|
||||||
|
"more_details_tab": "More Details",
|
||||||
|
"quick_facts": "Quick Facts",
|
||||||
|
"quick_facts_hint": "Your at-a-glance snapshot of core limits.",
|
||||||
|
"more_details_link": "See even more details",
|
||||||
|
"badge_deep_dive": "Deep Dive",
|
||||||
"breakdown_label": "At-a-glance",
|
"breakdown_label": "At-a-glance",
|
||||||
|
"breakdown_label_hint": "Dive deeper into how the package is structured.",
|
||||||
"limits_label": "Limits & Capacity",
|
"limits_label": "Limits & Capacity",
|
||||||
|
"limits_label_hint": "Understand the exact limits for planning and approvals.",
|
||||||
"for_endcustomers": "For End Customers",
|
"for_endcustomers": "For End Customers",
|
||||||
"for_resellers": "For Resellers",
|
"for_resellers": "For Resellers",
|
||||||
"details_show": "Show Details",
|
"details_show": "Show Details",
|
||||||
@@ -372,7 +382,12 @@
|
|||||||
"next_to_payment": "Next to Payment",
|
"next_to_payment": "Next to Payment",
|
||||||
"switch_to_register": "Register",
|
"switch_to_register": "Register",
|
||||||
"switch_to_login": "Login",
|
"switch_to_login": "Login",
|
||||||
"google_coming_soon": "Google Login coming soon in Comfort-Delta."
|
"continue_with_google": "Continue with Google",
|
||||||
|
"google_success_toast": "Signed in with Google.",
|
||||||
|
"google_error_title": "Google login failed",
|
||||||
|
"google_missing_package": "Please choose a package before using Google login.",
|
||||||
|
"google_missing_email": "We could not retrieve your Google email address.",
|
||||||
|
"google_error_fallback": "We couldn't complete the Google login. Please try again."
|
||||||
},
|
},
|
||||||
"payment_step": {
|
"payment_step": {
|
||||||
"title": "Payment",
|
"title": "Payment",
|
||||||
@@ -383,6 +398,7 @@
|
|||||||
"activate_package": "Activate Package",
|
"activate_package": "Activate Package",
|
||||||
"loading_payment": "Payment data is loading...",
|
"loading_payment": "Payment data is loading...",
|
||||||
"secure_payment_desc": "Secure payment with credit card, debit card or SEPA direct debit.",
|
"secure_payment_desc": "Secure payment with credit card, debit card or SEPA direct debit.",
|
||||||
|
"secure_paypal_desc": "Pay securely with PayPal.",
|
||||||
"payment_failed": "Payment failed. ",
|
"payment_failed": "Payment failed. ",
|
||||||
"error_card": "Card error occurred.",
|
"error_card": "Card error occurred.",
|
||||||
"error_validation": "Input data is invalid.",
|
"error_validation": "Input data is invalid.",
|
||||||
@@ -398,7 +414,23 @@
|
|||||||
"pay_now": "Pay Now (${price})",
|
"pay_now": "Pay Now (${price})",
|
||||||
"stripe_not_loaded": "Stripe is not initialized. Please reload the page.",
|
"stripe_not_loaded": "Stripe is not initialized. Please reload the page.",
|
||||||
"network_error": "Network error loading payment data",
|
"network_error": "Network error loading payment data",
|
||||||
"payment_intent_error": "Error loading payment data"
|
"payment_intent_error": "Error loading payment data",
|
||||||
|
"paypal_order_error": "Could not create the PayPal order. Please try again.",
|
||||||
|
"paypal_capture_error": "PayPal capture failed. Please try again.",
|
||||||
|
"paypal_error": "PayPal reported an error.",
|
||||||
|
"paypal_cancelled": "You cancelled the PayPal payment.",
|
||||||
|
"paypal_missing_plan": "Missing PayPal plan configuration for this package. Please choose another payment method.",
|
||||||
|
"auth_required": "Please log in to continue to payment.",
|
||||||
|
"status_loading": "Preparing secure payment data…",
|
||||||
|
"status_ready": "Payment form ready. Enter your details to continue.",
|
||||||
|
"status_processing": "Processing payment with {{provider}}…",
|
||||||
|
"status_success": "Payment confirmed. Finalising your order…",
|
||||||
|
"status_info_title": "Payment status",
|
||||||
|
"status_error_title": "Payment failed",
|
||||||
|
"status_success_title": "Payment completed",
|
||||||
|
"status_retry": "Retry",
|
||||||
|
"method_stripe": "Credit Card (Stripe)",
|
||||||
|
"method_paypal": "PayPal"
|
||||||
},
|
},
|
||||||
"confirmation_step": {
|
"confirmation_step": {
|
||||||
"title": "Confirmation",
|
"title": "Confirmation",
|
||||||
@@ -421,7 +453,12 @@
|
|||||||
"already_logged_in": "You are already logged in as {email}.",
|
"already_logged_in": "You are already logged in as {email}.",
|
||||||
"switch_to_register": "Register",
|
"switch_to_register": "Register",
|
||||||
"switch_to_login": "Login",
|
"switch_to_login": "Login",
|
||||||
"google_coming_soon": "Google Login coming soon in Comfort-Delta."
|
"continue_with_google": "Continue with Google",
|
||||||
|
"google_success_toast": "Signed in with Google.",
|
||||||
|
"google_error_title": "Google login failed",
|
||||||
|
"google_missing_package": "Please choose a package before using Google login.",
|
||||||
|
"google_missing_email": "We could not retrieve your Google email address.",
|
||||||
|
"google_error_fallback": "We couldn't complete the Google login. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import i18n from './i18n';
|
|||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { Elements } from '@stripe/react-stripe-js';
|
import { Elements } from '@stripe/react-stripe-js';
|
||||||
import { loadStripe } from '@stripe/stripe-js';
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
import { ConsentProvider } from './contexts/consent';
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
@@ -42,10 +43,12 @@ createInertiaApp({
|
|||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<Elements stripe={stripePromise}>
|
<Elements stripe={stripePromise}>
|
||||||
|
<ConsentProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
|
</ConsentProvider>
|
||||||
</Elements>
|
</Elements>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
104
resources/js/components/analytics/MatomoTracker.tsx
Normal file
104
resources/js/components/analytics/MatomoTracker.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePage } from '@inertiajs/react';
|
||||||
|
import { useConsent } from '@/contexts/consent';
|
||||||
|
|
||||||
|
export type MatomoConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
url?: string;
|
||||||
|
siteId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
_paq?: any[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatomoTrackerProps {
|
||||||
|
config: MatomoConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
|
||||||
|
const page = usePage();
|
||||||
|
const { hasConsent } = useConsent();
|
||||||
|
const analyticsConsent = hasConsent('analytics');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config?.enabled || !config.url || !config.siteId || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = config.url.replace(/\/$/, '');
|
||||||
|
const scriptSelector = `script[data-matomo="${base}"]`;
|
||||||
|
|
||||||
|
if (!analyticsConsent) {
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(scriptSelector);
|
||||||
|
existing?.remove();
|
||||||
|
if (window._paq) {
|
||||||
|
window._paq.length = 0;
|
||||||
|
}
|
||||||
|
delete (window as any).__matomoInitialized;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window._paq = window._paq || [];
|
||||||
|
const { _paq } = window;
|
||||||
|
|
||||||
|
if (!(window as any).__matomoInitialized) {
|
||||||
|
_paq.push(['setTrackerUrl', `${base}/matomo.php`]);
|
||||||
|
_paq.push(['setSiteId', config.siteId]);
|
||||||
|
_paq.push(['disableCookies']);
|
||||||
|
_paq.push(['enableLinkTracking']);
|
||||||
|
|
||||||
|
if (!document.querySelector(scriptSelector)) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = `${base}/matomo.js`;
|
||||||
|
script.dataset.matomo = base;
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).__matomoInitialized = true;
|
||||||
|
}
|
||||||
|
}, [config, analyticsConsent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!config?.enabled ||
|
||||||
|
!config.url ||
|
||||||
|
!config.siteId ||
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!analyticsConsent
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window._paq = window._paq || [];
|
||||||
|
const { _paq } = window;
|
||||||
|
const currentUrl =
|
||||||
|
typeof window !== 'undefined' ? `${window.location.origin}${page.url}` : page.url;
|
||||||
|
|
||||||
|
_paq.push(['setCustomUrl', currentUrl]);
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
_paq.push(['setDocumentTitle', document.title]);
|
||||||
|
}
|
||||||
|
_paq.push(['trackPageView']);
|
||||||
|
}, [config, analyticsConsent, page.url]);
|
||||||
|
|
||||||
|
if (!config?.enabled || !config.url || !config.siteId || !analyticsConsent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = config.url.replace(/\/$/, '');
|
||||||
|
const noscriptSrc = `${base}/matomo.php?idsite=${encodeURIComponent(config.siteId)}&rec=1`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<noscript>
|
||||||
|
<p>
|
||||||
|
<img src={noscriptSrc} style={{ border: 0 }} alt="" />
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatomoTracker;
|
||||||
175
resources/js/components/consent/CookieBanner.tsx
Normal file
175
resources/js/components/consent/CookieBanner.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useConsent, ConsentPreferences } from '@/contexts/consent';
|
||||||
|
|
||||||
|
const CookieBanner: React.FC = () => {
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
const {
|
||||||
|
showBanner,
|
||||||
|
acceptAll,
|
||||||
|
rejectAll,
|
||||||
|
preferences,
|
||||||
|
savePreferences,
|
||||||
|
isPreferencesOpen,
|
||||||
|
openPreferences,
|
||||||
|
closePreferences,
|
||||||
|
} = useConsent();
|
||||||
|
|
||||||
|
const [draftPreferences, setDraftPreferences] = useState<ConsentPreferences>(preferences);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPreferencesOpen) {
|
||||||
|
setDraftPreferences(preferences);
|
||||||
|
}
|
||||||
|
}, [isPreferencesOpen, preferences]);
|
||||||
|
|
||||||
|
const analyticsDescription = useMemo(
|
||||||
|
() => t('consent.modal.analytics_desc'),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
savePreferences({ analytics: draftPreferences.analytics });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
openPreferences();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closePreferences();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showBanner && !isPreferencesOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 bottom-0 z-40 px-4 pb-6 sm:px-6"
|
||||||
|
role="region"
|
||||||
|
aria-label={t('consent.accessibility.banner_label')}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-5xl rounded-3xl border border-gray-200 bg-white/95 p-6 shadow-2xl backdrop-blur-md dark:border-gray-700 dark:bg-gray-900/95">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('consent.banner.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t('consent.banner.body')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="min-w-[140px]"
|
||||||
|
onClick={rejectAll}
|
||||||
|
>
|
||||||
|
{t('consent.banner.reject')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="min-w-[140px]"
|
||||||
|
onClick={openPreferences}
|
||||||
|
>
|
||||||
|
{t('consent.banner.customize')}
|
||||||
|
</Button>
|
||||||
|
<Button className="min-w-[140px]" onClick={acceptAll}>
|
||||||
|
{t('consent.banner.accept')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={isPreferencesOpen} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-xl md:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('consent.modal.title')}</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm text-muted-foreground">
|
||||||
|
{t('consent.modal.description')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-800/60">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('consent.modal.functional')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('consent.modal.functional_desc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-gray-200 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{t('consent.modal.required')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-gray-200 p-4 dark:border-gray-700">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('consent.modal.analytics')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{analyticsDescription}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draftPreferences.analytics}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraftPreferences((prev) => ({
|
||||||
|
...prev,
|
||||||
|
analytics: checked,
|
||||||
|
functional: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
aria-label={t('consent.modal.analytics')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
|
||||||
|
<Button type="button" variant="outline" onClick={rejectAll}>
|
||||||
|
{t('consent.modal.reject_all')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={acceptAll}>
|
||||||
|
{t('consent.modal.accept_all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => closePreferences()}
|
||||||
|
>
|
||||||
|
{t('consent.modal.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleSave}>
|
||||||
|
{t('consent.modal.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CookieBanner;
|
||||||
185
resources/js/contexts/consent.tsx
Normal file
185
resources/js/contexts/consent.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
const CONSENT_STORAGE_KEY = 'fotospiel.consent';
|
||||||
|
const CONSENT_VERSION = '2025-10-17-1';
|
||||||
|
|
||||||
|
export type ConsentCategory = 'functional' | 'analytics';
|
||||||
|
|
||||||
|
export type ConsentPreferences = Record<ConsentCategory, boolean>;
|
||||||
|
|
||||||
|
interface StoredConsent {
|
||||||
|
version: string;
|
||||||
|
preferences: ConsentPreferences;
|
||||||
|
decisionMade: boolean;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPreferences: ConsentPreferences = {
|
||||||
|
functional: true,
|
||||||
|
analytics: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultState: StoredConsent = {
|
||||||
|
version: CONSENT_VERSION,
|
||||||
|
preferences: { ...defaultPreferences },
|
||||||
|
decisionMade: false,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConsentContextValue {
|
||||||
|
preferences: ConsentPreferences;
|
||||||
|
decisionMade: boolean;
|
||||||
|
showBanner: boolean;
|
||||||
|
acceptAll: () => void;
|
||||||
|
rejectAll: () => void;
|
||||||
|
savePreferences: (preferences: Partial<ConsentPreferences>) => void;
|
||||||
|
hasConsent: (category: ConsentCategory) => boolean;
|
||||||
|
openPreferences: () => void;
|
||||||
|
closePreferences: () => void;
|
||||||
|
isPreferencesOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConsentContext = createContext<ConsentContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
function normalizeState(state: StoredConsent | null): StoredConsent {
|
||||||
|
if (!state || state.version !== CONSENT_VERSION) {
|
||||||
|
return { ...defaultState };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: CONSENT_VERSION,
|
||||||
|
decisionMade: state.decisionMade ?? false,
|
||||||
|
updatedAt: state.updatedAt ?? null,
|
||||||
|
preferences: {
|
||||||
|
...defaultPreferences,
|
||||||
|
...state.preferences,
|
||||||
|
functional: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialState(): StoredConsent {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { ...defaultState };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CONSENT_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return { ...defaultState };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as StoredConsent;
|
||||||
|
return normalizeState(parsed);
|
||||||
|
} catch {
|
||||||
|
return { ...defaultState };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [state, setState] = useState<StoredConsent>(() => getInitialState());
|
||||||
|
const [isPreferencesOpen, setPreferencesOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(state));
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const acceptAll = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
version: CONSENT_VERSION,
|
||||||
|
preferences: { functional: true, analytics: true },
|
||||||
|
decisionMade: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setPreferencesOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rejectAll = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
version: CONSENT_VERSION,
|
||||||
|
preferences: { functional: true, analytics: false },
|
||||||
|
decisionMade: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setPreferencesOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const savePreferences = useCallback((preferences: Partial<ConsentPreferences>) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
version: CONSENT_VERSION,
|
||||||
|
preferences: {
|
||||||
|
...defaultPreferences,
|
||||||
|
...prev.preferences,
|
||||||
|
...preferences,
|
||||||
|
functional: true,
|
||||||
|
},
|
||||||
|
decisionMade: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
setPreferencesOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasConsent = useCallback(
|
||||||
|
(category: ConsentCategory) => {
|
||||||
|
return Boolean(state.preferences?.[category]);
|
||||||
|
},
|
||||||
|
[state.preferences],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openPreferences = useCallback(() => {
|
||||||
|
setPreferencesOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closePreferences = useCallback(() => {
|
||||||
|
setPreferencesOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ConsentContextValue>(
|
||||||
|
() => ({
|
||||||
|
preferences: state.preferences,
|
||||||
|
decisionMade: state.decisionMade,
|
||||||
|
showBanner: !state.decisionMade,
|
||||||
|
acceptAll,
|
||||||
|
rejectAll,
|
||||||
|
savePreferences,
|
||||||
|
hasConsent,
|
||||||
|
openPreferences,
|
||||||
|
closePreferences,
|
||||||
|
isPreferencesOpen,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
state.preferences,
|
||||||
|
state.decisionMade,
|
||||||
|
acceptAll,
|
||||||
|
rejectAll,
|
||||||
|
savePreferences,
|
||||||
|
hasConsent,
|
||||||
|
openPreferences,
|
||||||
|
closePreferences,
|
||||||
|
isPreferencesOpen,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConsent = (): ConsentContextValue => {
|
||||||
|
const context = useContext(ConsentContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useConsent must be used within a ConsentProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -16,8 +16,8 @@ interface EmotionPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
||||||
const { token: slug } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const eventKey = slug ?? '';
|
const eventKey = token ?? '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [emotions, setEmotions] = useState<Emotion[]>([]);
|
const [emotions, setEmotions] = useState<Emotion[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { getDeviceId } from '../lib/device';
|
import { getDeviceId } from '../lib/device';
|
||||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||||
|
|
||||||
type Props = { slug: string };
|
type Props = { token: string };
|
||||||
|
|
||||||
export default function GalleryPreview({ slug }: Props) {
|
export default function GalleryPreview({ token }: Props) {
|
||||||
const { photos, loading } = usePollGalleryDelta(slug);
|
const { photos, loading } = usePollGalleryDelta(token);
|
||||||
const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest');
|
const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest');
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
const items = React.useMemo(() => {
|
||||||
@@ -82,7 +81,7 @@ export default function GalleryPreview({ slug }: Props) {
|
|||||||
My Photos
|
My Photos
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Link to={`/e/${encodeURIComponent(slug)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
|
<Link to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
|
||||||
Alle ansehen →
|
Alle ansehen →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +96,7 @@ export default function GalleryPreview({ slug }: Props) {
|
|||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{items.map((p: any) => (
|
{items.map((p: any) => (
|
||||||
<Link key={p.id} to={`/e/${encodeURIComponent(slug)}/gallery?photoId=${p.id}`} className="block">
|
<Link key={p.id} to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`} className="block">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
src={p.thumbnail_path || p.file_path}
|
src={p.thumbnail_path || p.file_path}
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ function renderEventAvatar(name: string, icon: unknown) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
|
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
|
||||||
const statsContext = useOptionalEventStats();
|
const statsContext = useOptionalEventStats();
|
||||||
const identity = useOptionalGuestIdentity();
|
const identity = useOptionalGuestIdentity();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!slug) {
|
if (!eventToken) {
|
||||||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||||
@@ -95,7 +95,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
|||||||
|
|
||||||
const { event, status } = useEventData();
|
const { event, status } = useEventData();
|
||||||
const guestName =
|
const guestName =
|
||||||
identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
|
identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null;
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
@@ -114,7 +114,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stats =
|
const stats =
|
||||||
statsContext && statsContext.eventKey === slug ? statsContext : undefined;
|
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||||
|
|||||||
@@ -272,17 +272,17 @@ function SummaryCards({ data }: { data: AchievementsPayload }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PersonalActions({ slug }: { slug: string }) {
|
function PersonalActions({ token }: { token: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={`/e/${encodeURIComponent(slug)}/upload`} className="flex items-center gap-2">
|
<Link to={`/e/${encodeURIComponent(token)}/upload`} className="flex items-center gap-2">
|
||||||
<Camera className="h-4 w-4" />
|
<Camera className="h-4 w-4" />
|
||||||
Neues Foto hochladen
|
Neues Foto hochladen
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to={`/e/${encodeURIComponent(slug)}/tasks`} className="flex items-center gap-2">
|
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Aufgabe ziehen
|
Aufgabe ziehen
|
||||||
</Link>
|
</Link>
|
||||||
@@ -292,7 +292,7 @@ function PersonalActions({ slug }: { slug: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AchievementsPage() {
|
export default function AchievementsPage() {
|
||||||
const { token: slug } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const identity = useGuestIdentity();
|
const identity = useGuestIdentity();
|
||||||
const [data, setData] = useState<AchievementsPayload | null>(null);
|
const [data, setData] = useState<AchievementsPayload | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -302,12 +302,12 @@ export default function AchievementsPage() {
|
|||||||
const personalName = identity.hydrated && identity.name ? identity.name : undefined;
|
const personalName = identity.hydrated && identity.name ? identity.name : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!token) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
fetchAchievements(slug, personalName, controller.signal)
|
fetchAchievements(token, personalName, controller.signal)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
setData(payload);
|
setData(payload);
|
||||||
if (!payload.personal) {
|
if (!payload.personal) {
|
||||||
@@ -322,11 +322,11 @@ export default function AchievementsPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [slug, personalName]);
|
}, [token, personalName]);
|
||||||
|
|
||||||
const hasPersonal = Boolean(data?.personal);
|
const hasPersonal = Boolean(data?.personal);
|
||||||
|
|
||||||
if (!slug) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ export default function AchievementsPage() {
|
|||||||
{data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes
|
{data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<PersonalActions slug={slug} />
|
<PersonalActions token={token} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Page } from './_util';
|
import { Page } from './_util';
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||||
@@ -12,8 +12,8 @@ import PhotoLightbox from './PhotoLightbox';
|
|||||||
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
const { token: slug } = useParams<{ token?: string }>();
|
const { token } = useParams<{ token?: string }>();
|
||||||
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug ?? '');
|
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
|
||||||
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||||
@@ -38,15 +38,15 @@ export default function GalleryPage() {
|
|||||||
|
|
||||||
// Load event and package info
|
// Load event and package info
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!token) return;
|
||||||
|
|
||||||
const loadEventData = async () => {
|
const loadEventData = async () => {
|
||||||
try {
|
try {
|
||||||
setEventLoading(true);
|
setEventLoading(true);
|
||||||
const [eventData, packageData, statsData] = await Promise.all([
|
const [eventData, packageData, statsData] = await Promise.all([
|
||||||
fetchEvent(slug),
|
fetchEvent(token),
|
||||||
getEventPackage(slug),
|
getEventPackage(token),
|
||||||
fetchStats(slug),
|
fetchStats(token),
|
||||||
]);
|
]);
|
||||||
setEvent(eventData);
|
setEvent(eventData);
|
||||||
setEventPackage(packageData);
|
setEventPackage(packageData);
|
||||||
@@ -59,7 +59,7 @@ export default function GalleryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadEventData();
|
loadEventData();
|
||||||
}, [slug]);
|
}, [token]);
|
||||||
|
|
||||||
const myPhotoIds = React.useMemo(() => {
|
const myPhotoIds = React.useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -99,7 +99,7 @@ export default function GalleryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!slug) {
|
if (!token) {
|
||||||
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
|
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ export default function GalleryPage() {
|
|||||||
currentIndex={currentPhotoIndex}
|
currentIndex={currentPhotoIndex}
|
||||||
onClose={() => setCurrentPhotoIndex(null)}
|
onClose={() => setCurrentPhotoIndex(null)}
|
||||||
onIndexChange={(index: number) => setCurrentPhotoIndex(index)}
|
onIndexChange={(index: number) => setCurrentPhotoIndex(index)}
|
||||||
slug={slug}
|
token={token}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<EmotionPicker />
|
<EmotionPicker />
|
||||||
|
|
||||||
<GalleryPreview slug={token} />
|
<GalleryPreview token={token} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export default function LandingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const targetKey = data.join_token ?? data.slug ?? normalized;
|
const targetKey = data.join_token ?? '';
|
||||||
|
if (!targetKey) {
|
||||||
|
setErrorKey('eventClosed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const storedName = readGuestName(targetKey);
|
const storedName = readGuestName(targetKey);
|
||||||
if (!storedName) {
|
if (!storedName) {
|
||||||
nav(`/setup/${encodeURIComponent(targetKey)}`);
|
nav(`/setup/${encodeURIComponent(targetKey)}`);
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ interface Props {
|
|||||||
currentIndex?: number;
|
currentIndex?: number;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onIndexChange?: (index: number) => void;
|
onIndexChange?: (index: number) => void;
|
||||||
slug?: string;
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, slug }: Props) {
|
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, token }: Props) {
|
||||||
const params = useParams<{ token?: string; photoId?: string }>();
|
const params = useParams<{ token?: string; photoId?: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const photoId = params.photoId;
|
const photoId = params.photoId;
|
||||||
const eventSlug = params.token || slug;
|
const eventToken = params.token || token;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
||||||
@@ -53,7 +53,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
|
|
||||||
// Fetch single photo for standalone mode
|
// Fetch single photo for standalone mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStandalone && photoId && !standalonePhoto && eventSlug) {
|
if (isStandalone && photoId && !standalonePhoto && eventToken) {
|
||||||
const fetchPhoto = async () => {
|
const fetchPhoto = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -80,7 +80,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
} else if (!isStandalone) {
|
} else if (!isStandalone) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state, t]);
|
}, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t]);
|
||||||
|
|
||||||
// Update likes when photo changes
|
// Update likes when photo changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -133,7 +133,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
|
|
||||||
// Load task info if photo has task_id and event key is available
|
// Load task info if photo has task_id and event key is available
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!photo?.task_id || !eventSlug) {
|
if (!photo?.task_id || !eventToken) {
|
||||||
setTask(null);
|
setTask(null);
|
||||||
setTaskLoading(false);
|
setTaskLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +144,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
(async () => {
|
(async () => {
|
||||||
setTaskLoading(true);
|
setTaskLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventSlug)}/tasks`);
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/tasks`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const tasks = await res.json();
|
const tasks = await res.json();
|
||||||
const foundTask = tasks.find((t: any) => t.id === taskId);
|
const foundTask = tasks.find((t: any) => t.id === taskId);
|
||||||
@@ -175,7 +175,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
setTaskLoading(false);
|
setTaskLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [photo?.task_id, eventSlug, t]);
|
}, [photo?.task_id, eventToken, t]);
|
||||||
|
|
||||||
async function onLike() {
|
async function onLike() {
|
||||||
if (liked || !photo) return;
|
if (liked || !photo) return;
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ const TASK_PROGRESS_TARGET = 5;
|
|||||||
const TIMER_VIBRATION = [0, 60, 120, 60];
|
const TIMER_VIBRATION = [0, 60, 120, 60];
|
||||||
|
|
||||||
export default function TaskPickerPage() {
|
export default function TaskPickerPage() {
|
||||||
const { token: slug } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const eventKey = slug ?? '';
|
const eventKey = token ?? '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -92,12 +92,12 @@ export default function TaskPickerPage() {
|
|||||||
map.set(task.emotion.slug, task.emotion.name);
|
map.set(task.emotion.slug, task.emotion.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
|
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: tokenValue, name }));
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
const filteredTasks = React.useMemo(() => {
|
const filteredTasks = React.useMemo(() => {
|
||||||
if (selectedEmotion === 'all') return tasks;
|
if (selectedEmotion === 'all') return tasks;
|
||||||
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
|
return tasks.filter((task) => task.emotion?.token === selectedEmotion);
|
||||||
}, [tasks, selectedEmotion]);
|
}, [tasks, selectedEmotion]);
|
||||||
|
|
||||||
const selectRandomTask = React.useCallback(
|
const selectRandomTask = React.useCallback(
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ const DEFAULT_PREFS: CameraPreferences = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const { token: slug } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const eventKey = slug ?? '';
|
const eventKey = token ?? '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { appearance } = useAppearance();
|
const { appearance } = useAppearance();
|
||||||
const isDarkMode = appearance === 'dark';
|
const isDarkMode = appearance === 'dark';
|
||||||
const { markCompleted } = useGuestTaskProgress(slug);
|
const { markCompleted } = useGuestTaskProgress(token);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const taskIdParam = searchParams.get('task');
|
const taskIdParam = searchParams.get('task');
|
||||||
@@ -138,7 +138,7 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
// Load task metadata
|
// Load task metadata
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug || !taskId) {
|
if (!token || !taskId) {
|
||||||
setTaskError(t('upload.loadError.title'));
|
setTaskError(t('upload.loadError.title'));
|
||||||
setLoadingTask(false);
|
setLoadingTask(false);
|
||||||
return;
|
return;
|
||||||
@@ -545,7 +545,7 @@ export default function UploadPage() {
|
|||||||
if (!supportsCamera && !task) {
|
if (!supportsCamera && !task) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||||
<main className="px-4 py-6">
|
<main className="px-4 py-6">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||||
@@ -559,7 +559,7 @@ export default function UploadPage() {
|
|||||||
if (loadingTask) {
|
if (loadingTask) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||||
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
||||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||||
@@ -572,7 +572,7 @@ export default function UploadPage() {
|
|||||||
if (!canUpload) {
|
if (!canUpload) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||||
<main className="px-4 py-6">
|
<main className="px-4 py-6">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
@@ -638,7 +638,7 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||||
<main className="relative flex flex-col gap-4 pb-4">
|
<main className="relative flex flex-col gap-4 pb-4">
|
||||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||||
{renderPrimer()}
|
{renderPrimer()}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string };
|
type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string };
|
||||||
|
|
||||||
export function usePollGalleryDelta(slug: string) {
|
export function usePollGalleryDelta(token: string) {
|
||||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [newCount, setNewCount] = useState(0);
|
const [newCount, setNewCount] = useState(0);
|
||||||
@@ -13,14 +13,14 @@ export function usePollGalleryDelta(slug: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function fetchDelta() {
|
async function fetchDelta() {
|
||||||
if (!slug) {
|
if (!token) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
|
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
|
||||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/photos${qs}`, {
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(token)}/photos${qs}`, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ export function usePollGalleryDelta(slug: string) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) {
|
if (!token) {
|
||||||
setPhotos([]);
|
setPhotos([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -107,7 +107,7 @@ export function usePollGalleryDelta(slug: string) {
|
|||||||
return () => {
|
return () => {
|
||||||
if (timer.current) window.clearInterval(timer.current);
|
if (timer.current) window.clearInterval(timer.current);
|
||||||
};
|
};
|
||||||
}, [slug, visible]);
|
}, [token, visible]);
|
||||||
|
|
||||||
function acknowledgeNew() { setNewCount(0); }
|
function acknowledgeNew() { setNewCount(0); }
|
||||||
return { loading, photos, newCount, acknowledgeNew };
|
return { loading, photos, newCount, acknowledgeNew };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type SyncManager = { register(tag: string): Promise<void>; };
|
|||||||
|
|
||||||
export type QueueItem = {
|
export type QueueItem = {
|
||||||
id?: number;
|
id?: number;
|
||||||
slug: string;
|
eventToken: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
emotion_id?: number | null;
|
emotion_id?: number | null;
|
||||||
@@ -77,7 +77,7 @@ async function attemptUpload(it: QueueItem): Promise<boolean> {
|
|||||||
if (!navigator.onLine) return false;
|
if (!navigator.onLine) return false;
|
||||||
try {
|
try {
|
||||||
const json = await createUpload(
|
const json = await createUpload(
|
||||||
`/api/v1/events/${encodeURIComponent(it.slug)}/photos`,
|
`/api/v1/events/${encodeURIComponent(it.eventToken)}/photos`,
|
||||||
it,
|
it,
|
||||||
getDeviceId(),
|
getDeviceId(),
|
||||||
(pct) => {
|
(pct) => {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function EventBoundary({ token }: { token: string }) {
|
|||||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||||
<EventStatsProvider eventKey={token}>
|
<EventStatsProvider eventKey={token}>
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
<Header slug={token} />
|
<Header eventToken={token} />
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +119,7 @@ function SetupLayout() {
|
|||||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||||
<EventStatsProvider eventKey={token}>
|
<EventStatsProvider eventKey={token}>
|
||||||
<div className="pb-0">
|
<div className="pb-0">
|
||||||
<Header slug={token} />
|
<Header eventToken={token} />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</EventStatsProvider>
|
</EventStatsProvider>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function safeString(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAchievements(
|
export async function fetchAchievements(
|
||||||
slug: string,
|
eventToken: string,
|
||||||
guestName?: string,
|
guestName?: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<AchievementsPayload> {
|
): Promise<AchievementsPayload> {
|
||||||
@@ -96,7 +96,7 @@ export async function fetchAchievements(
|
|||||||
params.set('guest_name', guestName.trim());
|
params.set('guest_name', guestName.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/achievements?${params.toString()}`, {
|
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/achievements?${params.toString()}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Device-Id': getDeviceId(),
|
'X-Device-Id': getDeviceId(),
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ export async function fetchStats(eventKey: string): Promise<EventStats> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventPackage(slug: string): Promise<EventPackage | null> {
|
export async function getEventPackage(eventToken: string): Promise<EventPackage | null> {
|
||||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/package`);
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/package`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
throw new Error('Failed to load event package');
|
throw new Error('Failed to load event package');
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ export async function likePhoto(id: number): Promise<number> {
|
|||||||
return json.likes_count ?? json.data?.likes_count ?? 0;
|
return json.likes_count ?? json.data?.likes_count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadPhoto(slug: string, file: File, taskId?: number, emotionSlug?: string): Promise<number> {
|
export async function uploadPhoto(eventToken: string, file: File, taskId?: number, emotionSlug?: string): Promise<number> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('photo', file, `photo-${Date.now()}.jpg`);
|
formData.append('photo', file, `photo-${Date.now()}.jpg`);
|
||||||
if (taskId) formData.append('task_id', taskId.toString());
|
if (taskId) formData.append('task_id', taskId.toString());
|
||||||
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
|
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
|
||||||
formData.append('device_id', getDeviceId());
|
formData.append('device_id', getDeviceId());
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/upload`, {
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|||||||
44
resources/js/hooks/useAnalytics.ts
Normal file
44
resources/js/hooks/useAnalytics.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useConsent } from '@/contexts/consent';
|
||||||
|
|
||||||
|
type AnalyticsEvent = {
|
||||||
|
category: string;
|
||||||
|
action: string;
|
||||||
|
name?: string;
|
||||||
|
value?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAnalytics() {
|
||||||
|
const { hasConsent } = useConsent();
|
||||||
|
|
||||||
|
const trackEvent = useCallback(
|
||||||
|
({ category, action, name, value }: AnalyticsEvent) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasConsent('analytics')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = (window._paq = window._paq || []);
|
||||||
|
const payload: (string | number)[] = ['trackEvent', category, action];
|
||||||
|
|
||||||
|
if (typeof name === 'string') {
|
||||||
|
payload.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (payload.length === 3) {
|
||||||
|
payload.push('');
|
||||||
|
}
|
||||||
|
payload.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.push(payload);
|
||||||
|
},
|
||||||
|
[hasConsent],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { trackEvent };
|
||||||
|
}
|
||||||
58
resources/js/hooks/useCtaExperiment.ts
Normal file
58
resources/js/hooks/useCtaExperiment.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
|
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||||
|
|
||||||
|
type CtaVariant = 'gradient' | 'neutral';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'marketing_cta_variant_v1';
|
||||||
|
|
||||||
|
function determineVariant(): CtaVariant {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'gradient';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'gradient' || stored === 'neutral') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assigned: CtaVariant = Math.random() < 0.5 ? 'gradient' : 'neutral';
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, assigned);
|
||||||
|
} catch (error) {
|
||||||
|
// localStorage may be unavailable; ignore and fallback to the assigned variant in memory
|
||||||
|
console.warn('Unable to persist CTA variant assignment', error);
|
||||||
|
}
|
||||||
|
return assigned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCtaExperiment(placement: string) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const [variant] = useState<CtaVariant>(() => determineVariant());
|
||||||
|
|
||||||
|
const trackingLabel = useMemo(() => `${placement}:${variant}`, [placement, variant]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_experiment',
|
||||||
|
action: 'cta_impression',
|
||||||
|
name: trackingLabel,
|
||||||
|
});
|
||||||
|
}, [trackEvent, trackingLabel]);
|
||||||
|
|
||||||
|
const trackClick = useCallback(() => {
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_experiment',
|
||||||
|
action: 'cta_click',
|
||||||
|
name: trackingLabel,
|
||||||
|
});
|
||||||
|
}, [trackEvent, trackingLabel]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
variant,
|
||||||
|
trackClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React from 'react';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { usePage } from '@inertiajs/react';
|
import { useConsent } from '@/contexts/consent';
|
||||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
|
||||||
|
|
||||||
|
|
||||||
const Footer: React.FC = () => {
|
const Footer: React.FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation(['marketing', 'legal']);
|
const { t } = useTranslation(['marketing', 'legal', 'common']);
|
||||||
|
const { openPreferences } = useConsent();
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +34,15 @@ const Footer: React.FC = () => {
|
|||||||
<li><Link href="/datenschutz" className="hover:text-pink-500 transition-colors">{t('legal:datenschutz')}</Link></li>
|
<li><Link href="/datenschutz" className="hover:text-pink-500 transition-colors">{t('legal:datenschutz')}</Link></li>
|
||||||
<li><Link href="/agb" className="hover:text-pink-500 transition-colors">{t('legal:agb')}</Link></li>
|
<li><Link href="/agb" className="hover:text-pink-500 transition-colors">{t('legal:agb')}</Link></li>
|
||||||
<li><Link href="/kontakt" className="hover:text-pink-500 transition-colors">{t('marketing:nav.contact')}</Link></li>
|
<li><Link href="/kontakt" className="hover:text-pink-500 transition-colors">{t('marketing:nav.contact')}</Link></li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openPreferences}
|
||||||
|
className="hover:text-pink-500 transition-colors"
|
||||||
|
>
|
||||||
|
{t('common:consent.footer.manage_link')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const Header: React.FC = () => {
|
|||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link href={localizedPath('/')} className="flex items-center gap-4">
|
<Link href={localizedPath('/')} className="flex items-center gap-4">
|
||||||
<img src="logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
<img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
||||||
<span className="text-2xl font-bold font-display text-pink-500">
|
<span className="text-2xl font-bold font-display text-pink-500">
|
||||||
FotoSpiel.App
|
FotoSpiel.App
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Head, Link, usePage, router } from '@inertiajs/react';
|
import { Head, usePage, router } from '@inertiajs/react';
|
||||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import MatomoTracker, { MatomoConfig } from '@/components/analytics/MatomoTracker';
|
||||||
|
import CookieBanner from '@/components/consent/CookieBanner';
|
||||||
|
|
||||||
interface MarketingLayoutProps {
|
interface MarketingLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -9,12 +10,15 @@ interface MarketingLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => {
|
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => {
|
||||||
const page = usePage<{ translations?: Record<string, Record<string, string>> }>();
|
const page = usePage<{
|
||||||
|
translations?: Record<string, Record<string, string>>;
|
||||||
|
locale?: string;
|
||||||
|
analytics?: { matomo?: MatomoConfig };
|
||||||
|
}>();
|
||||||
const { url } = page;
|
const { url } = page;
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const i18n = useTranslation();
|
const i18n = useTranslation();
|
||||||
const { locale } = usePage().props as any;
|
const { locale, analytics } = page.props;
|
||||||
const { localizedPath } = useLocalizedRoutes();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (locale && i18n.i18n.language !== locale) {
|
if (locale && i18n.i18n.language !== locale) {
|
||||||
@@ -61,6 +65,8 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
|||||||
<link rel="canonical" href={canonicalUrl} />
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
<link rel="alternate" hrefLang="x-default" href="https://fotospiel.app/" />
|
<link rel="alternate" hrefLang="x-default" href="https://fotospiel.app/" />
|
||||||
</Head>
|
</Head>
|
||||||
|
<MatomoTracker config={analytics?.matomo} />
|
||||||
|
<CookieBanner />
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<header className="bg-white shadow-sm">
|
<header className="bg-white shadow-sm">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Head, Link, useForm } from '@inertiajs/react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||||
import MarketingLayout from '@/layouts/mainWebsite';
|
import MarketingLayout from '@/layouts/mainWebsite';
|
||||||
|
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||||
|
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||||||
|
|
||||||
interface Package {
|
interface Package {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -18,6 +20,11 @@ interface Props {
|
|||||||
const Home: React.FC<Props> = ({ packages }) => {
|
const Home: React.FC<Props> = ({ packages }) => {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { localizedPath } = useLocalizedRoutes();
|
const { localizedPath } = useLocalizedRoutes();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const {
|
||||||
|
variant: heroCtaVariant,
|
||||||
|
trackClick: trackHeroCtaClick,
|
||||||
|
} = useCtaExperiment('home_hero_cta');
|
||||||
const { data, setData, post, processing, errors, reset } = useForm({
|
const { data, setData, post, processing, errors, reset } = useForm({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -27,7 +34,13 @@ const Home: React.FC<Props> = ({ packages }) => {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
post(localizedPath('/kontakt'), {
|
post(localizedPath('/kontakt'), {
|
||||||
onSuccess: () => reset(),
|
onSuccess: () => {
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_home',
|
||||||
|
action: 'contact_submit',
|
||||||
|
});
|
||||||
|
reset();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,9 +62,22 @@ const Home: React.FC<Props> = ({ packages }) => {
|
|||||||
<p className="text-xl md:text-2xl mb-8 font-sans-marketing">{t('home.hero_description')}</p>
|
<p className="text-xl md:text-2xl mb-8 font-sans-marketing">{t('home.hero_description')}</p>
|
||||||
<Link
|
<Link
|
||||||
href={localizedPath('/packages')}
|
href={localizedPath('/packages')}
|
||||||
className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-300 inline-block"
|
onClick={() => {
|
||||||
|
trackHeroCtaClick();
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_home',
|
||||||
|
action: 'hero_cta',
|
||||||
|
name: `packages:${heroCtaVariant}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'inline-block rounded-full px-8 py-4 font-bold transition duration-300',
|
||||||
|
heroCtaVariant === 'gradient'
|
||||||
|
? 'bg-gradient-to-r from-rose-500 via-pink-500 to-amber-400 text-white shadow-lg shadow-rose-500/40 hover:from-rose-500/95 hover:via-pink-500/95 hover:to-amber-400/95'
|
||||||
|
: 'bg-white text-[#FFB6C1] hover:bg-gray-100 dark:bg-gray-800 dark:text-rose-200 dark:hover:bg-gray-700',
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{t('home.cta_explore')}
|
{heroCtaVariant === 'gradient' ? t('home.cta_explore_highlight') : t('home.cta_explore')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:w-1/2">
|
<div className="md:w-1/2">
|
||||||
@@ -125,14 +151,34 @@ const Home: React.FC<Props> = ({ packages }) => {
|
|||||||
<h3 className="text-2xl font-bold mb-2">{pkg.name}</h3>
|
<h3 className="text-2xl font-bold mb-2">{pkg.name}</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-4">{pkg.description}</p>
|
<p className="text-gray-600 dark:text-gray-300 mb-4">{pkg.description}</p>
|
||||||
<p className="text-3xl font-bold text-[#FFB6C1]">{pkg.price} {t('currency.euro')}</p>
|
<p className="text-3xl font-bold text-[#FFB6C1]">{pkg.price} {t('currency.euro')}</p>
|
||||||
<Link href={`${localizedPath('/register')}?package_id=${pkg.id}`} className="mt-4 inline-block bg-[#FFB6C1] text-white px-6 py-2 rounded-full hover:bg-pink-600">
|
<Link
|
||||||
|
href={`${localizedPath('/packages')}?package_id=${pkg.id}`}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_home',
|
||||||
|
action: 'package_teaser_cta',
|
||||||
|
name: pkg.name,
|
||||||
|
value: pkg.price,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-4 inline-block bg-[#FFB6C1] text-white px-6 py-2 rounded-full hover:bg-pink-600"
|
||||||
|
>
|
||||||
{t('home.view_details')}
|
{t('home.view_details')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href={localizedPath('/packages')} className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
|
<Link
|
||||||
|
href={localizedPath('/packages')}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_home',
|
||||||
|
action: 'all_packages_cta',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition"
|
||||||
|
>
|
||||||
{t('home.all_packages')}
|
{t('home.all_packages')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import MarketingLayout from '@/layouts/mainWebsite';
|
import MarketingLayout from '@/layouts/mainWebsite';
|
||||||
|
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||||
|
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||||||
import { ArrowRight, ShoppingCart, Check, X, Users, Image, Shield, Star, Sparkles } from 'lucide-react';
|
import { ArrowRight, ShoppingCart, Check, X, Users, Image, Shield, Star, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
interface Package {
|
interface Package {
|
||||||
@@ -50,11 +52,15 @@ interface PackagesProps {
|
|||||||
const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => {
|
const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
|
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState('step1');
|
const [currentStep, setCurrentStep] = useState<'overview' | 'deep' | 'testimonials'>('overview');
|
||||||
const { props } = usePage();
|
const { props } = usePage();
|
||||||
const { auth } = props as any;
|
const { auth } = props as any;
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { t: tCommon } = useTranslation('common');
|
const { t: tCommon } = useTranslation('common');
|
||||||
|
const {
|
||||||
|
variant: packagesHeroVariant,
|
||||||
|
trackClick: trackPackagesHeroClick,
|
||||||
|
} = useCtaExperiment('packages_hero_cta');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -65,7 +71,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
if (pkg) {
|
if (pkg) {
|
||||||
setSelectedPackage(pkg);
|
setSelectedPackage(pkg);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
setCurrentStep('step1');
|
setCurrentStep('overview');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [endcustomerPackages, resellerPackages]);
|
}, [endcustomerPackages, resellerPackages]);
|
||||||
@@ -78,27 +84,34 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
|
|
||||||
const allPackages = [...endcustomerPackages, ...resellerPackages];
|
const allPackages = [...endcustomerPackages, ...resellerPackages];
|
||||||
|
|
||||||
const highlightEndcustomerId = useMemo(() => {
|
const selectHighlightPackageId = (packages: Package[]): number | null => {
|
||||||
if (!endcustomerPackages.length) {
|
const count = packages.length;
|
||||||
|
if (count <= 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const best = endcustomerPackages.reduce((prev, current) => {
|
|
||||||
if (!prev) return current;
|
|
||||||
return current.price > prev.price ? current : prev;
|
|
||||||
}, null as Package | null);
|
|
||||||
return best?.id ?? endcustomerPackages[0].id;
|
|
||||||
}, [endcustomerPackages]);
|
|
||||||
|
|
||||||
const highlightResellerId = useMemo(() => {
|
const sortedByPrice = [...packages].sort((a, b) => a.price - b.price);
|
||||||
if (!resellerPackages.length) {
|
|
||||||
return null;
|
if (count === 2) {
|
||||||
|
return sortedByPrice[1]?.id ?? null;
|
||||||
}
|
}
|
||||||
const best = resellerPackages.reduce((prev, current) => {
|
|
||||||
if (!prev) return current;
|
if (count === 3) {
|
||||||
return current.price > prev.price ? current : prev;
|
return sortedByPrice[1]?.id ?? null;
|
||||||
}, null as Package | null);
|
}
|
||||||
return best?.id ?? resellerPackages[0].id;
|
|
||||||
}, [resellerPackages]);
|
return sortedByPrice[count - 2]?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightEndcustomerId = useMemo(
|
||||||
|
() => selectHighlightPackageId(endcustomerPackages),
|
||||||
|
[endcustomerPackages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightResellerId = useMemo(
|
||||||
|
() => selectHighlightPackageId(resellerPackages),
|
||||||
|
[resellerPackages],
|
||||||
|
);
|
||||||
|
|
||||||
function isHighlightedPackage(pkg: Package, variant: 'endcustomer' | 'reseller') {
|
function isHighlightedPackage(pkg: Package, variant: 'endcustomer' | 'reseller') {
|
||||||
return variant === 'reseller' ? pkg.id === highlightResellerId : pkg.id === highlightEndcustomerId;
|
return variant === 'reseller' ? pkg.id === highlightResellerId : pkg.id === highlightEndcustomerId;
|
||||||
@@ -113,12 +126,29 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
? isHighlightedPackage(selectedPackage, selectedVariant)
|
? isHighlightedPackage(selectedPackage, selectedVariant)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const handleCardClick = (pkg: Package) => {
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleCardClick = (pkg: Package, variant: 'endcustomer' | 'reseller') => {
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_packages',
|
||||||
|
action: 'open_dialog',
|
||||||
|
name: `${variant}:${pkg.name}`,
|
||||||
|
value: pkg.price,
|
||||||
|
});
|
||||||
setSelectedPackage(pkg);
|
setSelectedPackage(pkg);
|
||||||
setCurrentStep('step1');
|
setCurrentStep('overview');
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCtaClick = (pkg: Package, variant: 'endcustomer' | 'reseller') => {
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_packages',
|
||||||
|
action: 'cta_dialog',
|
||||||
|
name: `${variant}:${pkg.name}`,
|
||||||
|
value: pkg.price,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// nextStep entfernt, da Tabs nun parallel sind
|
// nextStep entfernt, da Tabs nun parallel sind
|
||||||
|
|
||||||
const getFeatureIcon = (feature: string) => {
|
const getFeatureIcon = (feature: string) => {
|
||||||
@@ -428,8 +458,24 @@ function PackageCard({
|
|||||||
<div className="container mx-auto text-center">
|
<div className="container mx-auto text-center">
|
||||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('packages.hero_title')}</h1>
|
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('packages.hero_title')}</h1>
|
||||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">{t('packages.hero_description')}</p>
|
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">{t('packages.hero_description')}</p>
|
||||||
<Link href="#endcustomer" className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing hover:bg-gray-100 dark:hover:bg-gray-700 transition">
|
<Link
|
||||||
{t('packages.cta_explore')}
|
href="#endcustomer"
|
||||||
|
onClick={() => {
|
||||||
|
trackPackagesHeroClick();
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_packages',
|
||||||
|
action: 'hero_cta',
|
||||||
|
name: `endcustomer:${packagesHeroVariant}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-8 py-4 text-lg font-semibold font-sans-marketing transition duration-300',
|
||||||
|
packagesHeroVariant === 'gradient'
|
||||||
|
? 'bg-gradient-to-r from-rose-500 via-pink-500 to-amber-400 text-white shadow-lg shadow-rose-500/40 hover:from-rose-500/95 hover:via-pink-500/95 hover:to-amber-400/95'
|
||||||
|
: 'bg-white text-[#FFB6C1] hover:bg-gray-100 dark:bg-gray-800 dark:text-rose-200 dark:hover:bg-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{packagesHeroVariant === 'gradient' ? t('packages.cta_explore_highlight') : t('packages.cta_explore')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -447,7 +493,7 @@ function PackageCard({
|
|||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
variant="endcustomer"
|
variant="endcustomer"
|
||||||
highlight={pkg.id === highlightEndcustomerId}
|
highlight={pkg.id === highlightEndcustomerId}
|
||||||
onSelect={handleCardClick}
|
onSelect={(pkg) => handleCardClick(pkg, 'endcustomer')}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
@@ -465,7 +511,7 @@ function PackageCard({
|
|||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
variant="endcustomer"
|
variant="endcustomer"
|
||||||
highlight={pkg.id === highlightEndcustomerId}
|
highlight={pkg.id === highlightEndcustomerId}
|
||||||
onSelect={handleCardClick}
|
onSelect={(pkg) => handleCardClick(pkg, 'endcustomer')}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -615,7 +661,7 @@ function PackageCard({
|
|||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
variant="reseller"
|
variant="reseller"
|
||||||
highlight={pkg.id === highlightResellerId}
|
highlight={pkg.id === highlightResellerId}
|
||||||
onSelect={handleCardClick}
|
onSelect={(pkg) => handleCardClick(pkg, 'reseller')}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
@@ -633,7 +679,7 @@ function PackageCard({
|
|||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
variant="reseller"
|
variant="reseller"
|
||||||
highlight={pkg.id === highlightResellerId}
|
highlight={pkg.id === highlightResellerId}
|
||||||
onSelect={handleCardClick}
|
onSelect={(pkg) => handleCardClick(pkg, 'reseller')}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -715,15 +761,20 @@ function PackageCard({
|
|||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs value={currentStep} onValueChange={setCurrentStep} className="w-full">
|
<Tabs value={currentStep} onValueChange={setCurrentStep} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 rounded-full bg-white/60 p-1 text-sm shadow-sm dark:bg-gray-900/60">
|
<TabsList className="grid w-full grid-cols-3 rounded-full bg-white/60 p-1 text-sm shadow-sm dark:bg-gray-900/60">
|
||||||
<TabsTrigger className="rounded-full" value="step1">{t('packages.details')}</TabsTrigger>
|
<TabsTrigger className="rounded-full" value="overview">{t('packages.details')}</TabsTrigger>
|
||||||
<TabsTrigger className="rounded-full" value="step2">{t('packages.customer_opinions')}</TabsTrigger>
|
<TabsTrigger className="rounded-full" value="deep">{t('packages.more_details_tab')}</TabsTrigger>
|
||||||
|
<TabsTrigger className="rounded-full" value="testimonials">{t('packages.customer_opinions')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="step1" className="mt-6 space-y-6">
|
<TabsContent value="overview" className="mt-6 space-y-6">
|
||||||
{(() => {
|
{(() => {
|
||||||
const accent = getAccentTheme(selectedVariant);
|
const accent = getAccentTheme(selectedVariant);
|
||||||
const metrics = resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon);
|
const metrics = resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon);
|
||||||
const descriptionEntries = selectedPackage.description_breakdown ?? [];
|
const topFeatureBadges = selectedPackage.features.slice(0, 3);
|
||||||
|
const hasMoreFeatures = selectedPackage.features.length > topFeatureBadges.length;
|
||||||
|
const quickFacts = metrics.slice(0, 2);
|
||||||
|
const showDeepLink =
|
||||||
|
hasMoreFeatures || (selectedPackage.description_breakdown?.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
|
||||||
@@ -740,17 +791,21 @@ function PackageCard({
|
|||||||
'radial-gradient(circle at top left, rgba(255,182,193,0.45), transparent 55%), radial-gradient(circle at bottom right, rgba(250,204,21,0.35), transparent 55%)',
|
'radial-gradient(circle at top left, rgba(255,182,193,0.45), transparent 55%), radial-gradient(circle at bottom right, rgba(250,204,21,0.35), transparent 55%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative space-y-4">
|
<div className="relative flex h-full flex-col justify-between gap-5">
|
||||||
|
<div className="space-y-5">
|
||||||
<Badge className="inline-flex w-fit items-center gap-1 rounded-full bg-gray-900/90 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white shadow-md dark:bg-white/90 dark:text-gray-900">
|
<Badge className="inline-flex w-fit items-center gap-1 rounded-full bg-gray-900/90 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white shadow-md dark:bg-white/90 dark:text-gray-900">
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
{t('packages.features_label')}
|
{t('packages.feature_highlights')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedPackage.features.map((feature) => (
|
{topFeatureBadges.map((feature) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={feature}
|
key={feature}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1 rounded-full border-transparent bg-white/80 px-3 py-1 text-xs font-medium text-gray-700 shadow-sm dark:bg-gray-800/70 dark:text-gray-200"
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-full border-transparent bg-white/80 px-3 py-1 text-xs font-medium text-gray-700 shadow-sm transition-colors hover:bg-white dark:bg-gray-800/70 dark:text-gray-200',
|
||||||
|
selectedHighlight && 'bg-white/85 dark:bg-white/10',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{getFeatureIcon(feature)}
|
{getFeatureIcon(feature)}
|
||||||
<span>{t(`packages.feature_${feature}`)}</span>
|
<span>{t(`packages.feature_${feature}`)}</span>
|
||||||
@@ -770,34 +825,143 @@ function PackageCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-3">
|
||||||
<div className="space-y-6">
|
{showDeepLink && (
|
||||||
{descriptionEntries.length > 0 && (
|
<button
|
||||||
<div className="rounded-3xl border border-gray-200/80 bg-white/90 p-6 shadow-xl dark:border-gray-700/70 dark:bg-gray-900/90">
|
type="button"
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-[0.3em] text-gray-400 dark:text-gray-500">
|
onClick={() => setCurrentStep('deep')}
|
||||||
{t('packages.breakdown_label')}
|
className="inline-flex items-center gap-2 text-sm font-semibold text-rose-500 transition-colors hover:text-rose-600 dark:text-rose-300 dark:hover:text-rose-200"
|
||||||
</h3>
|
|
||||||
<div className="mt-4 grid gap-3">
|
|
||||||
{descriptionEntries.map((entry, index) => (
|
|
||||||
<div
|
|
||||||
key={`${entry.title}-${index}`}
|
|
||||||
className="rounded-2xl bg-gradient-to-r from-rose-50/90 via-white to-white p-4 shadow-sm dark:from-gray-800 dark:via-gray-900 dark:to-gray-900"
|
|
||||||
>
|
>
|
||||||
{entry.title && (
|
{t('packages.more_details_link')}
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-rose-500 dark:text-rose-200">
|
<ArrowRight className="h-4 w-4" />
|
||||||
{entry.title}
|
</button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-center gap-2 rounded-full py-3 text-base font-semibold transition-all duration-300',
|
||||||
|
accent.ctaShadow,
|
||||||
|
selectedHighlight ? accent.buttonHighlight : accent.buttonDefault,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/purchase-wizard/${selectedPackage.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedPackage) {
|
||||||
|
handleCtaClick(selectedPackage, selectedVariant);
|
||||||
|
}
|
||||||
|
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('packages.to_order')}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-center text-gray-500 dark:text-gray-400">
|
||||||
|
{t('packages.order_hint')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">{entry.value}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-full flex-col gap-4 rounded-3xl border border-gray-200/70 bg-white/90 p-6 shadow-lg dark:border-gray-700/70 dark:bg-gray-900/85">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('packages.quick_facts')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t('packages.quick_facts_hint')}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{quickFacts.map((metric) => (
|
||||||
|
<li
|
||||||
|
key={metric.key}
|
||||||
|
className="rounded-2xl border border-gray-200/70 bg-white/80 p-4 dark:border-gray-700/70 dark:bg-gray-800/70"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-semibold text-gray-900 dark:text-white">{metric.value}</p>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{metric.label}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
{showDeepLink && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-auto w-full justify-center rounded-full border-rose-200/70 text-rose-600 hover:bg-rose-50 dark:border-rose-500/40 dark:text-rose-200 dark:hover:bg-rose-500/10"
|
||||||
|
onClick={() => setCurrentStep('deep')}
|
||||||
|
>
|
||||||
|
{t('packages.more_details_link')}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="rounded-3xl border border-gray-200/80 bg-white/90 p-6 shadow-xl dark:border-gray-700/70 dark:bg-gray-900/90">
|
</div>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-[0.3em] text-gray-400 dark:text-gray-500">
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="deep" className="mt-6 space-y-6">
|
||||||
|
{(() => {
|
||||||
|
const accent = getAccentTheme(selectedVariant);
|
||||||
|
const metrics = resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon);
|
||||||
|
const descriptionEntries = selectedPackage.description_breakdown ?? [];
|
||||||
|
const entriesWithTitle = descriptionEntries.filter((entry) => entry.title);
|
||||||
|
const entriesWithoutTitle = descriptionEntries.filter((entry) => !entry.title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-3xl border border-gray-200/70 bg-white/95 p-6 shadow-lg dark:border-gray-700/70 dark:bg-gray-900/85">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge className="rounded-full bg-gray-900/90 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white shadow-md dark:bg-white/90 dark:text-gray-900">
|
||||||
|
{t('packages.features_label')}
|
||||||
|
</Badge>
|
||||||
|
{selectedHighlight && (
|
||||||
|
<Badge className="rounded-full bg-gradient-to-r from-rose-500 via-pink-500 to-amber-400 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white shadow-sm">
|
||||||
|
{t('packages.badge_deep_dive')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{selectedPackage.features.map((feature) => (
|
||||||
|
<Badge
|
||||||
|
key={feature}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-full border-transparent bg-white/85 px-3 py-1 text-xs font-medium text-gray-700 shadow-sm transition-colors hover:bg-white dark:bg-gray-800/70 dark:text-gray-200',
|
||||||
|
selectedHighlight && 'bg-white/85 dark:bg-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getFeatureIcon(feature)}
|
||||||
|
<span>{t(`packages.feature_${feature}`)}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{selectedPackage.watermark_allowed === false && (
|
||||||
|
<Badge className="flex items-center gap-1 rounded-full bg-emerald-100/80 px-3 py-1 text-xs font-medium text-emerald-700 shadow-sm dark:bg-emerald-500/20 dark:text-emerald-100">
|
||||||
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
{t('packages.no_watermark')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{selectedPackage.branding_allowed && (
|
||||||
|
<Badge className="flex items-center gap-1 rounded-full bg-sky-100/80 px-3 py-1 text-xs font-medium text-sky-700 shadow-sm dark:bg-sky-500/20 dark:text-sky-100">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{t('packages.custom_branding')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{metrics.length > 0 && (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
'rounded-3xl border border-gray-200/70 bg-white/95 p-6 shadow-lg dark:border-gray-700/70 dark:bg-gray-900/85',
|
||||||
|
selectedHighlight && `ring-2 ${accent.ring}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{t('packages.limits_label')}
|
{t('packages.limits_label')}
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t('packages.limits_label_hint')}
|
||||||
|
</p>
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
{metrics.map((metric) => (
|
{metrics.map((metric) => (
|
||||||
<div
|
<div
|
||||||
@@ -811,41 +975,59 @@ function PackageCard({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div className="space-y-3">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-center gap-2 rounded-full py-3 text-base font-semibold transition-all duration-300',
|
|
||||||
accent.ctaShadow,
|
|
||||||
selectedHighlight ? accent.buttonHighlight : accent.buttonDefault,
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<Link
|
{descriptionEntries.length > 0 && (
|
||||||
href={`/purchase-wizard/${selectedPackage.id}`}
|
<section className="rounded-3xl border border-gray-200/70 bg-white/95 p-6 shadow-lg dark:border-gray-700/70 dark:bg-gray-900/85">
|
||||||
onClick={() => {
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
|
{t('packages.breakdown_label')}
|
||||||
}}
|
</h3>
|
||||||
>
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
{t('packages.to_order')}
|
{t('packages.breakdown_label_hint')}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-center text-gray-500 dark:text-gray-400">
|
|
||||||
{t('packages.order_hint')}
|
|
||||||
</p>
|
</p>
|
||||||
|
{entriesWithTitle.length > 0 && (
|
||||||
|
<Accordion type="single" collapsible className="mt-4 space-y-3">
|
||||||
|
{entriesWithTitle.map((entry, index) => (
|
||||||
|
<AccordionItem
|
||||||
|
key={`${entry.title}-${index}`}
|
||||||
|
value={`entry-${index}`}
|
||||||
|
className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/85 shadow-sm dark:border-gray-700/70 dark:bg-gray-900/80"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-4 text-left text-sm font-semibold text-gray-900 hover:no-underline dark:text-white">
|
||||||
|
{entry.title}
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4 text-sm text-gray-600 dark:text-gray-300 whitespace-pre-line">
|
||||||
|
{entry.value}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
{entriesWithoutTitle.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{entriesWithoutTitle.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`plain-${index}`}
|
||||||
|
className="rounded-2xl border border-gray-200/70 bg-white/85 p-4 text-sm text-gray-600 shadow-sm dark:border-gray-700/70 dark:bg-gray-900/80 dark:text-gray-300 whitespace-pre-line"
|
||||||
|
>
|
||||||
|
{entry.value}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="step2" className="mt-6">
|
<TabsContent value="testimonials" className="mt-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h3 className="text-xl font-semibold font-display text-gray-900 dark:text-white">
|
<h3 className="text-xl font-semibold font-display text-gray-900 dark:text-white">
|
||||||
{t('packages.what_customers_say')}
|
{t('packages.what_customers_say')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="flex flex-col gap-4">
|
||||||
{testimonials.map((testimonial, index) => (
|
{testimonials.map((testimonial, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useRef, useEffect } from "react";
|
import React, { useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Steps } from "@/components/ui/Steps";
|
import { Steps } from "@/components/ui/Steps";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -9,6 +9,7 @@ import { PackageStep } from "./steps/PackageStep";
|
|||||||
import { AuthStep } from "./steps/AuthStep";
|
import { AuthStep } from "./steps/AuthStep";
|
||||||
import { PaymentStep } from "./steps/PaymentStep";
|
import { PaymentStep } from "./steps/PaymentStep";
|
||||||
import { ConfirmationStep } from "./steps/ConfirmationStep";
|
import { ConfirmationStep } from "./steps/ConfirmationStep";
|
||||||
|
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||||
|
|
||||||
interface CheckoutWizardProps {
|
interface CheckoutWizardProps {
|
||||||
initialPackage: CheckoutPackage;
|
initialPackage: CheckoutPackage;
|
||||||
@@ -56,6 +57,7 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
|
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
|
||||||
const progressRef = useRef<HTMLDivElement | null>(null);
|
const progressRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hasMountedRef = useRef(false);
|
const hasMountedRef = useRef(false);
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const stepConfig = useMemo(() =>
|
const stepConfig = useMemo(() =>
|
||||||
baseStepConfig.map(step => ({
|
baseStepConfig.map(step => ({
|
||||||
@@ -75,6 +77,14 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
return (currentIndex / (stepConfig.length - 1)) * 100;
|
return (currentIndex / (stepConfig.length - 1)) * 100;
|
||||||
}, [currentIndex, stepConfig]);
|
}, [currentIndex, stepConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_checkout',
|
||||||
|
action: 'step_view',
|
||||||
|
name: currentStep,
|
||||||
|
});
|
||||||
|
}, [currentStep, trackEvent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined' || !progressRef.current) {
|
if (typeof window === 'undefined' || !progressRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -95,6 +105,34 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
});
|
});
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
const targetStep = stepConfig[currentIndex + 1]?.id ?? 'end';
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_checkout',
|
||||||
|
action: 'step_next',
|
||||||
|
name: `${currentStep}->${targetStep}`,
|
||||||
|
});
|
||||||
|
nextStep();
|
||||||
|
}, [currentIndex, currentStep, nextStep, stepConfig, trackEvent]);
|
||||||
|
|
||||||
|
const handlePrevious = useCallback(() => {
|
||||||
|
const targetStep = stepConfig[currentIndex - 1]?.id ?? 'start';
|
||||||
|
trackEvent({
|
||||||
|
category: 'marketing_checkout',
|
||||||
|
action: 'step_previous',
|
||||||
|
name: `${currentStep}->${targetStep}`,
|
||||||
|
});
|
||||||
|
previousStep();
|
||||||
|
}, [currentIndex, currentStep, previousStep, stepConfig, trackEvent]);
|
||||||
|
|
||||||
|
const handleViewProfile = useCallback(() => {
|
||||||
|
window.location.href = '/settings/profile';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGoToAdmin = useCallback(() => {
|
||||||
|
window.location.href = '/event-admin';
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div ref={progressRef} className="space-y-4">
|
<div ref={progressRef} className="space-y-4">
|
||||||
@@ -108,14 +146,16 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
{currentStep === "payment" && (
|
{currentStep === "payment" && (
|
||||||
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} />
|
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} />
|
||||||
)}
|
)}
|
||||||
{currentStep === "confirmation" && <ConfirmationStep />}
|
{currentStep === "confirmation" && (
|
||||||
|
<ConfirmationStep onViewProfile={handleViewProfile} onGoToAdmin={handleGoToAdmin} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" onClick={previousStep} disabled={currentIndex <= 0}>
|
<Button variant="ghost" onClick={handlePrevious} disabled={currentIndex <= 0}>
|
||||||
{t('checkout.back')}
|
{t('checkout.back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={nextStep} disabled={currentIndex >= stepConfig.length - 1}>
|
<Button onClick={handleNext} disabled={currentIndex >= stepConfig.length - 1}>
|
||||||
{t('checkout.next')}
|
{t('checkout.next')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { usePage } from "@inertiajs/react";
|
import { usePage } from "@inertiajs/react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
@@ -6,17 +6,52 @@ import { useCheckoutWizard } from "../WizardContext";
|
|||||||
import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
|
import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
|
||||||
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
|
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
interface AuthStepProps {
|
interface AuthStepProps {
|
||||||
privacyHtml: string;
|
privacyHtml: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GoogleAuthFlash = {
|
||||||
|
status?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path fill="#EA4335" d="M12 11.999v4.8h6.7c-.3 1.7-2 4.9-6.7 4.9-4 0-7.4-3.3-7.4-7.4S8 6 12 6c2.3 0 3.9 1 4.8 1.9l3.3-3.2C18.1 2.6 15.3 1 12 1 5.9 1 1 5.9 1 12s4.9 11 11 11c6.3 0 10.5-4.4 10.5-10.6 0-.7-.1-1.2-.2-1.8H12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const page = usePage<{ locale?: string }>();
|
const page = usePage<{ locale?: string }>();
|
||||||
const locale = page.props.locale ?? "de";
|
const locale = page.props.locale ?? "de";
|
||||||
|
const googleAuth = useMemo<GoogleAuthFlash>(() => {
|
||||||
|
const props = page.props as Record<string, any>;
|
||||||
|
return props.googleAuth ?? {};
|
||||||
|
}, [page.props]);
|
||||||
const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard();
|
const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard();
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('register');
|
const [mode, setMode] = useState<'login' | 'register'>('register');
|
||||||
|
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (googleAuth?.status === 'success') {
|
||||||
|
toast.success(t('checkout.auth_step.google_success_toast'));
|
||||||
|
}
|
||||||
|
}, [googleAuth?.status, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (googleAuth?.error) {
|
||||||
|
toast.error(googleAuth.error);
|
||||||
|
}
|
||||||
|
}, [googleAuth?.error]);
|
||||||
|
|
||||||
const handleLoginSuccess = (payload: AuthUserPayload | null) => {
|
const handleLoginSuccess = (payload: AuthUserPayload | null) => {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -46,6 +81,20 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
|||||||
nextStep();
|
nextStep();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = useCallback(() => {
|
||||||
|
if (!selectedPackage) {
|
||||||
|
toast.error(t('checkout.auth_step.google_missing_package'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRedirectingToGoogle(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
package_id: String(selectedPackage.id),
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
window.location.href = `/checkout/auth/google?${params.toString()}`;
|
||||||
|
}, [locale, selectedPackage, t]);
|
||||||
|
|
||||||
if (isAuthenticated && authUser) {
|
if (isAuthenticated && authUser) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -79,11 +128,28 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
|||||||
>
|
>
|
||||||
{t('checkout.auth_step.switch_to_login')}
|
{t('checkout.auth_step.switch_to_login')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<Button
|
||||||
{t('checkout.auth_step.google_coming_soon')}
|
variant="outline"
|
||||||
</span>
|
onClick={handleGoogleLogin}
|
||||||
|
disabled={isRedirectingToGoogle}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isRedirectingToGoogle ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<GoogleIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t('checkout.auth_step.continue_with_google')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{googleAuth?.error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>{t('checkout.auth_step.google_error_title')}</AlertTitle>
|
||||||
|
<AlertDescription>{googleAuth.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||||
{mode === 'register' ? (
|
{mode === 'register' ? (
|
||||||
selectedPackage && (
|
selectedPackage && (
|
||||||
|
|||||||
@@ -6,11 +6,27 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
interface ConfirmationStepProps {
|
interface ConfirmationStepProps {
|
||||||
onViewProfile?: () => void;
|
onViewProfile?: () => void;
|
||||||
|
onGoToAdmin?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile }) => {
|
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile, onGoToAdmin }) => {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { selectedPackage } = useCheckoutWizard();
|
const { selectedPackage } = useCheckoutWizard();
|
||||||
|
const handleProfile = React.useCallback(() => {
|
||||||
|
if (typeof onViewProfile === 'function') {
|
||||||
|
onViewProfile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/settings/profile';
|
||||||
|
}, [onViewProfile]);
|
||||||
|
|
||||||
|
const handleAdmin = React.useCallback(() => {
|
||||||
|
if (typeof onGoToAdmin === 'function') {
|
||||||
|
onGoToAdmin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/event-admin';
|
||||||
|
}, [onGoToAdmin]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -22,10 +38,10 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-wrap gap-3 justify-end">
|
<div className="flex flex-wrap gap-3 justify-end">
|
||||||
<Button variant="outline" onClick={onViewProfile}>
|
<Button variant="outline" onClick={handleProfile}>
|
||||||
{t('checkout.confirmation_step.open_profile')}
|
{t('checkout.confirmation_step.open_profile')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button>{t('checkout.confirmation_step.to_admin')}</Button>
|
<Button onClick={handleAdmin}>{t('checkout.confirmation_step.to_admin')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { Check, Package as PackageIcon, Loader2 } from "lucide-react";
|
import { Check, Package as PackageIcon, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -13,14 +14,19 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
|||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
function PackageSummary({ pkg }: { pkg: CheckoutPackage }) {
|
function translateFeature(feature: string, t: TFunction<'marketing'>) {
|
||||||
|
const fallback = feature.replace(/_/g, ' ');
|
||||||
|
return t(`packages.feature_${feature}`, { defaultValue: fallback });
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'marketing'> }) {
|
||||||
const isFree = pkg.price === 0;
|
const isFree = pkg.price === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`shadow-sm ${isFree ? "opacity-75" : ""}`}>
|
<Card className={`shadow-sm ${isFree ? 'opacity-75' : ''}`}>
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className={`flex items-center gap-3 text-2xl ${isFree ? "text-muted-foreground" : ""}`}>
|
<CardTitle className={`flex items-center gap-3 text-2xl ${isFree ? 'text-muted-foreground' : ''}`}>
|
||||||
<PackageIcon className={`h-6 w-6 ${isFree ? "text-muted-foreground" : "text-primary"}`} />
|
<PackageIcon className={`h-6 w-6 ${isFree ? 'text-muted-foreground' : 'text-primary'}`} />
|
||||||
{pkg.name}
|
{pkg.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-base text-muted-foreground">
|
<CardDescription className="text-base text-muted-foreground">
|
||||||
@@ -29,19 +35,41 @@ function PackageSummary({ pkg }: { pkg: CheckoutPackage }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className={`text-3xl font-semibold ${isFree ? "text-muted-foreground" : ""}`}>
|
<span className={`text-3xl font-semibold ${isFree ? 'text-muted-foreground' : ''}`}>
|
||||||
{pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)}
|
{pkg.price === 0 ? t('packages.free') : currencyFormatter.format(pkg.price)}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={isFree ? "outline" : "secondary"} className="uppercase tracking-wider text-xs">
|
<Badge variant={isFree ? 'outline' : 'secondary'} className="uppercase tracking-wider text-xs">
|
||||||
{pkg.type === "reseller" ? "Reseller" : "Endkunde"}
|
{pkg.type === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
{pkg.gallery_duration_label && (
|
||||||
|
<div className="rounded-md border border-dashed border-muted px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{t('packages.gallery_days_label')}: {pkg.gallery_duration_label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Array.isArray(pkg.description_breakdown) && pkg.description_breakdown.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('packages.breakdown_label')}
|
||||||
|
</h4>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{pkg.description_breakdown.map((row, index) => (
|
||||||
|
<div key={index} className="rounded-lg border border-muted/40 bg-muted/20 px-3 py-2">
|
||||||
|
{row.title && (
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{row.title}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">{row.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{Array.isArray(pkg.features) && pkg.features.length > 0 && (
|
{Array.isArray(pkg.features) && pkg.features.length > 0 && (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{pkg.features.map((feature, index) => (
|
{pkg.features.map((feature, index) => (
|
||||||
<li key={index} className="flex items-start gap-3 text-sm text-muted-foreground">
|
<li key={index} className="flex items-start gap-3 text-sm text-muted-foreground">
|
||||||
<Check className={`mt-0.5 h-4 w-4 ${isFree ? "text-muted-foreground" : "text-primary"}`} />
|
<Check className={`mt-0.5 h-4 w-4 ${isFree ? 'text-muted-foreground' : 'text-primary'}`} />
|
||||||
<span>{feature}</span>
|
<span>{translateFeature(feature, t)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -51,7 +79,7 @@ function PackageSummary({ pkg }: { pkg: CheckoutPackage }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackageOption({ pkg, isActive, onSelect }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void }) {
|
function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void; t: TFunction<'marketing'> }) {
|
||||||
const isFree = pkg.price === 0;
|
const isFree = pkg.price === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +97,7 @@ function PackageOption({ pkg, isActive, onSelect }: { pkg: CheckoutPackage; isAc
|
|||||||
<div className="flex items-center justify-between text-sm font-medium">
|
<div className="flex items-center justify-between text-sm font-medium">
|
||||||
<span className={isFree ? "text-muted-foreground" : ""}>{pkg.name}</span>
|
<span className={isFree ? "text-muted-foreground" : ""}>{pkg.name}</span>
|
||||||
<span className={isFree ? "text-muted-foreground font-normal" : "text-muted-foreground"}>
|
<span className={isFree ? "text-muted-foreground font-normal" : "text-muted-foreground"}>
|
||||||
{pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)}
|
{pkg.price === 0 ? t('packages.free') : currencyFormatter.format(pkg.price)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{pkg.description}</p>
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{pkg.description}</p>
|
||||||
@@ -125,7 +153,7 @@ export const PackageStep: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
|
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PackageSummary pkg={selectedPackage} />
|
<PackageSummary pkg={selectedPackage} t={t} />
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button size="lg" onClick={handleNextStep} disabled={isLoading}>
|
<Button size="lg" onClick={handleNextStep} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -150,6 +178,7 @@ export const PackageStep: React.FC = () => {
|
|||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
isActive={pkg.id === selectedPackage.id}
|
isActive={pkg.id === selectedPackage.id}
|
||||||
onSelect={() => handlePackageChange(pkg)}
|
onSelect={() => handlePackageChange(pkg)}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{comparablePackages.length === 0 && (
|
{comparablePackages.length === 0 && (
|
||||||
|
|||||||
@@ -1,35 +1,47 @@
|
|||||||
import { useState, useEffect } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
|
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
|
||||||
import { loadStripe } from '@stripe/stripe-js';
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Button } from '@/components/ui/button';
|
||||||
import { useCheckoutWizard } from "../WizardContext";
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
import { useCheckoutWizard } from '../WizardContext';
|
||||||
|
|
||||||
interface PaymentStepProps {
|
interface PaymentStepProps {
|
||||||
stripePublishableKey: string;
|
stripePublishableKey: string;
|
||||||
paypalClientId: string;
|
paypalClientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Komponente für Stripe-Zahlungen
|
type Provider = 'stripe' | 'paypal';
|
||||||
const StripePaymentForm: React.FC<{ onError: (error: string) => void; onSuccess: () => void; selectedPackage: any; t: any }> = ({ onError, onSuccess, selectedPackage, t }) => {
|
type PaymentStatus = 'idle' | 'loading' | 'ready' | 'processing' | 'error' | 'success';
|
||||||
|
|
||||||
|
interface StripePaymentFormProps {
|
||||||
|
onProcessing: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
selectedPackage: any;
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StripePaymentForm: React.FC<StripePaymentFormProps> = ({ onProcessing, onSuccess, onError, selectedPackage, t }) => {
|
||||||
const stripe = useStripe();
|
const stripe = useStripe();
|
||||||
const elements = useElements();
|
const elements = useElements();
|
||||||
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [error, setError] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!stripe || !elements) {
|
if (!stripe || !elements) {
|
||||||
onError(t('checkout.payment_step.stripe_not_loaded'));
|
const message = t('checkout.payment_step.stripe_not_loaded');
|
||||||
|
onError(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProcessing();
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setError('');
|
setErrorMessage('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error: stripeError, paymentIntent } = await stripe.confirmPayment({
|
const { error: stripeError, paymentIntent } = await stripe.confirmPayment({
|
||||||
@@ -41,38 +53,41 @@ const StripePaymentForm: React.FC<{ onError: (error: string) => void; onSuccess:
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (stripeError) {
|
if (stripeError) {
|
||||||
console.error('Stripe Payment Error:', stripeError);
|
let message = t('checkout.payment_step.payment_failed');
|
||||||
let errorMessage = t('checkout.payment_step.payment_failed');
|
|
||||||
|
|
||||||
switch (stripeError.type) {
|
switch (stripeError.type) {
|
||||||
case 'card_error':
|
case 'card_error':
|
||||||
errorMessage += stripeError.message || t('checkout.payment_step.error_card');
|
message += stripeError.message || t('checkout.payment_step.error_card');
|
||||||
break;
|
break;
|
||||||
case 'validation_error':
|
case 'validation_error':
|
||||||
errorMessage += t('checkout.payment_step.error_validation');
|
message += t('checkout.payment_step.error_validation');
|
||||||
break;
|
break;
|
||||||
case 'api_connection_error':
|
case 'api_connection_error':
|
||||||
errorMessage += t('checkout.payment_step.error_connection');
|
message += t('checkout.payment_step.error_connection');
|
||||||
break;
|
break;
|
||||||
case 'api_error':
|
case 'api_error':
|
||||||
errorMessage += t('checkout.payment_step.error_server');
|
message += t('checkout.payment_step.error_server');
|
||||||
break;
|
break;
|
||||||
case 'authentication_error':
|
case 'authentication_error':
|
||||||
errorMessage += t('checkout.payment_step.error_auth');
|
message += t('checkout.payment_step.error_auth');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
errorMessage += stripeError.message || t('checkout.payment_step.error_unknown');
|
message += stripeError.message || t('checkout.payment_step.error_unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(errorMessage);
|
setErrorMessage(message);
|
||||||
onError(errorMessage);
|
onError(message);
|
||||||
} else if (paymentIntent && paymentIntent.status === 'succeeded') {
|
return;
|
||||||
onSuccess();
|
|
||||||
} else if (paymentIntent) {
|
|
||||||
onError(t('checkout.payment_step.unexpected_status', { status: paymentIntent.status }));
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Unexpected payment error:', err);
|
if (paymentIntent && paymentIntent.status === 'succeeded') {
|
||||||
|
onSuccess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(t('checkout.payment_step.unexpected_status', { status: paymentIntent?.status }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stripe payment failed', error);
|
||||||
onError(t('checkout.payment_step.error_unknown'));
|
onError(t('checkout.payment_step.error_unknown'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
@@ -81,56 +96,83 @@ const StripePaymentForm: React.FC<{ onError: (error: string) => void; onSuccess:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{errorMessage && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{errorMessage}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
|
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.secure_payment_desc')}</p>
|
||||||
{t('checkout.payment_step.secure_payment_desc')}
|
|
||||||
</p>
|
|
||||||
<PaymentElement />
|
<PaymentElement />
|
||||||
<Button
|
<Button type="submit" disabled={!stripe || isProcessing} size="lg" className="w-full">
|
||||||
type="submit"
|
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
disabled={!stripe || isProcessing}
|
{t('checkout.payment_step.pay_now', { price: selectedPackage?.price || 0 })}
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isProcessing ? t('checkout.payment_step.processing_btn') : t('checkout.payment_step.pay_now', { price: selectedPackage?.price || 0 })}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Komponente für PayPal-Zahlungen
|
interface PayPalPaymentFormProps {
|
||||||
const PayPalPaymentForm: React.FC<{ onError: (error: string) => void; onSuccess: () => void; selectedPackage: any; t: any; authUser: any; paypalClientId: string }> = ({ onError, onSuccess, selectedPackage, t, authUser, paypalClientId }) => {
|
onProcessing: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
selectedPackage: any;
|
||||||
|
isReseller: boolean;
|
||||||
|
paypalPlanId?: string | null;
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PayPalPaymentForm: React.FC<PayPalPaymentFormProps> = ({ onProcessing, onSuccess, onError, selectedPackage, isReseller, paypalPlanId, t }) => {
|
||||||
const createOrder = async () => {
|
const createOrder = async () => {
|
||||||
|
if (!selectedPackage?.id) {
|
||||||
|
const message = t('checkout.payment_step.paypal_order_error');
|
||||||
|
onError(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/paypal/create-order', {
|
onProcessing();
|
||||||
|
|
||||||
|
const endpoint = isReseller ? '/paypal/create-subscription' : '/paypal/create-order';
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
package_id: selectedPackage.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isReseller) {
|
||||||
|
if (!paypalPlanId) {
|
||||||
|
const message = t('checkout.payment_step.paypal_missing_plan');
|
||||||
|
onError(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
payload.plan_id = paypalPlanId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
tenant_id: authUser?.tenant_id || authUser?.id, // Annahme: tenant_id verfügbar
|
|
||||||
package_id: selectedPackage?.id,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.id) {
|
if (response.ok) {
|
||||||
return data.id;
|
const orderId = isReseller ? data.order_id : data.id;
|
||||||
|
if (typeof orderId === 'string' && orderId.length > 0) {
|
||||||
|
return orderId;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onError(data.error || t('checkout.payment_step.paypal_order_error'));
|
onError(data.error || t('checkout.payment_step.paypal_order_error'));
|
||||||
throw new Error('Failed to create order');
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
|
throw new Error('Failed to create PayPal order');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PayPal create order failed', error);
|
||||||
onError(t('checkout.payment_step.network_error'));
|
onError(t('checkout.payment_step.network_error'));
|
||||||
throw err;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,9 +184,7 @@ const PayPalPaymentForm: React.FC<{ onError: (error: string) => void; onSuccess:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ order_id: data.orderID }),
|
||||||
order_id: data.orderID,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -154,57 +194,123 @@ const PayPalPaymentForm: React.FC<{ onError: (error: string) => void; onSuccess:
|
|||||||
} else {
|
} else {
|
||||||
onError(result.error || t('checkout.payment_step.paypal_capture_error'));
|
onError(result.error || t('checkout.payment_step.paypal_capture_error'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
|
console.error('PayPal capture failed', error);
|
||||||
onError(t('checkout.payment_step.network_error'));
|
onError(t('checkout.payment_step.network_error'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onErrorHandler = (error: any) => {
|
const handleError = (error: unknown) => {
|
||||||
console.error('PayPal Error:', error);
|
console.error('PayPal error', error);
|
||||||
onError(t('checkout.payment_step.paypal_error'));
|
onError(t('checkout.payment_step.paypal_error'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = () => {
|
const handleCancel = () => {
|
||||||
onError(t('checkout.payment_step.paypal_cancelled'));
|
onError(t('checkout.payment_step.paypal_cancelled'));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
|
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.secure_paypal_desc') || 'Bezahlen Sie sicher mit PayPal.'}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('checkout.payment_step.secure_paypal_desc') || 'Bezahlen Sie sicher mit PayPal.'}
|
|
||||||
</p>
|
|
||||||
<PayPalButtons
|
<PayPalButtons
|
||||||
style={{ layout: 'vertical' }}
|
style={{ layout: 'vertical' }}
|
||||||
createOrder={createOrder}
|
createOrder={async () => createOrder()}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onError={onErrorHandler}
|
onError={handleError}
|
||||||
onCancel={onCancel}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrapper-Komponente
|
const statusVariantMap: Record<PaymentStatus, 'default' | 'destructive' | 'success' | 'secondary'> = {
|
||||||
|
idle: 'secondary',
|
||||||
|
loading: 'secondary',
|
||||||
|
ready: 'secondary',
|
||||||
|
processing: 'secondary',
|
||||||
|
error: 'destructive',
|
||||||
|
success: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey, paypalClientId }) => {
|
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey, paypalClientId }) => {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { selectedPackage, authUser, nextStep, resetPaymentState } = useCheckoutWizard();
|
const { selectedPackage, authUser, nextStep, resetPaymentState } = useCheckoutWizard();
|
||||||
const [clientSecret, setClientSecret] = useState<string>('');
|
|
||||||
const [paymentMethod, setPaymentMethod] = useState<'stripe' | 'paypal'>('stripe');
|
const [paymentMethod, setPaymentMethod] = useState<Provider>('stripe');
|
||||||
const [error, setError] = useState<string>('');
|
const [clientSecret, setClientSecret] = useState('');
|
||||||
const [isFree, setIsFree] = useState(false);
|
const [status, setStatus] = useState<PaymentStatus>('idle');
|
||||||
|
const [statusDetail, setStatusDetail] = useState<string>('');
|
||||||
|
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
|
||||||
|
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null);
|
||||||
|
|
||||||
|
const stripePromise = useMemo(() => loadStripe(stripePublishableKey), [stripePublishableKey]);
|
||||||
|
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
|
||||||
|
const isReseller = selectedPackage?.type === 'reseller';
|
||||||
|
|
||||||
|
const paypalPlanId = useMemo(() => {
|
||||||
|
if (!selectedPackage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof selectedPackage.paypal_plan_id === 'string' && selectedPackage.paypal_plan_id.trim().length > 0) {
|
||||||
|
return selectedPackage.paypal_plan_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = (selectedPackage as Record<string, unknown>)?.metadata;
|
||||||
|
if (metadata && typeof metadata === 'object') {
|
||||||
|
const value = (metadata as Record<string, unknown>).paypal_plan_id;
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [selectedPackage]);
|
||||||
|
|
||||||
|
const paypalDisabled = isReseller && !paypalPlanId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const free = selectedPackage ? selectedPackage.price <= 0 : false;
|
setStatus('idle');
|
||||||
setIsFree(free);
|
setStatusDetail('');
|
||||||
if (free) {
|
setClientSecret('');
|
||||||
|
setProcessingProvider(null);
|
||||||
|
}, [selectedPackage?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFree) {
|
||||||
resetPaymentState();
|
resetPaymentState();
|
||||||
|
setStatus('ready');
|
||||||
|
setStatusDetail('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paymentMethod === 'stripe' && authUser && selectedPackage) {
|
if (!selectedPackage) {
|
||||||
const loadPaymentIntent = async () => {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentMethod === 'paypal') {
|
||||||
|
if (paypalDisabled) {
|
||||||
|
setStatus('error');
|
||||||
|
setStatusDetail(t('checkout.payment_step.paypal_missing_plan'));
|
||||||
|
} else {
|
||||||
|
setStatus('ready');
|
||||||
|
setStatusDetail('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authUser) {
|
||||||
|
setStatus('error');
|
||||||
|
setStatusDetail(t('checkout.payment_step.auth_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setStatus('loading');
|
||||||
|
setStatusDetail(t('checkout.payment_step.status_loading'));
|
||||||
|
setClientSecret('');
|
||||||
|
|
||||||
|
const loadIntent = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/stripe/create-payment-intent', {
|
const response = await fetch('/stripe/create-payment-intent', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -212,47 +318,85 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ package_id: selectedPackage.id }),
|
||||||
package_id: selectedPackage.id,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.client_secret) {
|
if (!response.ok || !data.client_secret) {
|
||||||
|
const message = data.error || t('checkout.payment_step.payment_intent_error');
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus('error');
|
||||||
|
setStatusDetail(message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
setClientSecret(data.client_secret);
|
setClientSecret(data.client_secret);
|
||||||
setError('');
|
setStatus('ready');
|
||||||
} else {
|
setStatusDetail(t('checkout.payment_step.status_ready'));
|
||||||
setError(data.error || t('checkout.payment_step.payment_intent_error'));
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Failed to load payment intent', error);
|
||||||
|
setStatus('error');
|
||||||
|
setStatusDetail(t('checkout.payment_step.network_error'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
setError(t('checkout.payment_step.network_error'));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPaymentIntent();
|
loadIntent();
|
||||||
} else {
|
|
||||||
setClientSecret('');
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, t]);
|
||||||
|
|
||||||
|
const providerLabel = useCallback((provider: Provider) => {
|
||||||
|
switch (provider) {
|
||||||
|
case 'paypal':
|
||||||
|
return 'PayPal';
|
||||||
|
default:
|
||||||
|
return 'Stripe';
|
||||||
}
|
}
|
||||||
}, [selectedPackage?.id, authUser, paymentMethod, isFree, t, resetPaymentState]);
|
}, []);
|
||||||
|
|
||||||
const handlePaymentError = (errorMsg: string) => {
|
const handleProcessing = useCallback((provider: Provider) => {
|
||||||
setError(errorMsg);
|
setProcessingProvider(provider);
|
||||||
|
setStatus('processing');
|
||||||
|
setStatusDetail(t('checkout.payment_step.status_processing', { provider: providerLabel(provider) }));
|
||||||
|
}, [providerLabel, t]);
|
||||||
|
|
||||||
|
const handleSuccess = useCallback((provider: Provider) => {
|
||||||
|
setProcessingProvider(provider);
|
||||||
|
setStatus('success');
|
||||||
|
setStatusDetail(t('checkout.payment_step.status_success'));
|
||||||
|
setTimeout(() => nextStep(), 600);
|
||||||
|
}, [nextStep, t]);
|
||||||
|
|
||||||
|
const handleError = useCallback((provider: Provider, message: string) => {
|
||||||
|
setProcessingProvider(provider);
|
||||||
|
setStatus('error');
|
||||||
|
setStatusDetail(message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
if (paymentMethod === 'stripe') {
|
||||||
|
setIntentRefreshKey((key) => key + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('idle');
|
||||||
|
setStatusDetail('');
|
||||||
|
setProcessingProvider(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePaymentSuccess = () => {
|
|
||||||
setTimeout(() => nextStep(), 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Für kostenlose Pakete
|
|
||||||
if (isFree) {
|
if (isFree) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertTitle>{t('checkout.payment_step.free_package_title')}</AlertTitle>
|
<AlertTitle>{t('checkout.payment_step.free_package_title')}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
|
||||||
{t('checkout.payment_step.free_package_desc')}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button size="lg" onClick={nextStep}>
|
<Button size="lg" onClick={nextStep}>
|
||||||
@@ -263,78 +407,93 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fehler anzeigen
|
const renderStatusAlert = () => {
|
||||||
if (error && !clientSecret) {
|
if (status === 'idle') {
|
||||||
return (
|
return null;
|
||||||
<div className="space-y-6">
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={() => setError('')}>Versuchen Sie es erneut</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripePromise = loadStripe(stripePublishableKey);
|
const variant = statusVariantMap[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant={variant}>
|
||||||
|
<AlertTitle>
|
||||||
|
{status === 'error'
|
||||||
|
? t('checkout.payment_step.status_error_title')
|
||||||
|
: status === 'success'
|
||||||
|
? t('checkout.payment_step.status_success_title')
|
||||||
|
: t('checkout.payment_step.status_info_title')}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className="flex items-center justify-between gap-4">
|
||||||
|
<span>{statusDetail}</span>
|
||||||
|
{status === 'processing' && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||||
|
{status === 'error' && (
|
||||||
|
<Button size="sm" variant="outline" onClick={handleRetry}>
|
||||||
|
{t('checkout.payment_step.status_retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Zahlungsmethode Auswahl */}
|
<div className="flex flex-wrap gap-3">
|
||||||
<div className="flex space-x-4">
|
|
||||||
<Button
|
<Button
|
||||||
variant={paymentMethod === 'stripe' ? 'default' : 'outline'}
|
variant={paymentMethod === 'stripe' ? 'default' : 'outline'}
|
||||||
onClick={() => setPaymentMethod('stripe')}
|
onClick={() => setPaymentMethod('stripe')}
|
||||||
className="flex-1"
|
disabled={paymentMethod === 'stripe'}
|
||||||
>
|
>
|
||||||
Kreditkarte (Stripe)
|
{t('checkout.payment_step.method_stripe')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={paymentMethod === 'paypal' ? 'default' : 'outline'}
|
variant={paymentMethod === 'paypal' ? 'default' : 'outline'}
|
||||||
onClick={() => setPaymentMethod('paypal')}
|
onClick={() => setPaymentMethod('paypal')}
|
||||||
className="flex-1"
|
disabled={paypalDisabled || paymentMethod === 'paypal'}
|
||||||
>
|
>
|
||||||
PayPal
|
{t('checkout.payment_step.method_paypal')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{renderStatusAlert()}
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{paymentMethod === 'stripe' && (
|
{paymentMethod === 'stripe' && clientSecret && (
|
||||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||||
<StripePaymentForm
|
<StripePaymentForm
|
||||||
onError={handlePaymentError}
|
|
||||||
onSuccess={handlePaymentSuccess}
|
|
||||||
selectedPackage={selectedPackage}
|
selectedPackage={selectedPackage}
|
||||||
|
onProcessing={() => handleProcessing('stripe')}
|
||||||
|
onSuccess={() => handleSuccess('stripe')}
|
||||||
|
onError={(message) => handleError('stripe', message)}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</Elements>
|
</Elements>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{paymentMethod === 'paypal' && (
|
{paymentMethod === 'stripe' && !clientSecret && status === 'loading' && (
|
||||||
|
<div className="rounded-lg border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
||||||
|
<LoaderCircle className="mb-3 h-4 w-4 animate-spin" />
|
||||||
|
{t('checkout.payment_step.status_loading')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paymentMethod === 'paypal' && !paypalDisabled && (
|
||||||
<PayPalScriptProvider options={{ clientId: paypalClientId, currency: 'EUR' }}>
|
<PayPalScriptProvider options={{ clientId: paypalClientId, currency: 'EUR' }}>
|
||||||
<PayPalPaymentForm
|
<PayPalPaymentForm
|
||||||
onError={handlePaymentError}
|
isReseller={Boolean(isReseller)}
|
||||||
onSuccess={handlePaymentSuccess}
|
onProcessing={() => handleProcessing('paypal')}
|
||||||
|
onSuccess={() => handleSuccess('paypal')}
|
||||||
|
onError={(message) => handleError('paypal', message)}
|
||||||
|
paypalPlanId={paypalPlanId}
|
||||||
selectedPackage={selectedPackage}
|
selectedPackage={selectedPackage}
|
||||||
t={t}
|
t={t}
|
||||||
authUser={authUser}
|
|
||||||
paypalClientId={paypalClientId}
|
|
||||||
/>
|
/>
|
||||||
</PayPalScriptProvider>
|
</PayPalScriptProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!clientSecret && paymentMethod === 'stripe' && (
|
{paymentMethod === 'paypal' && paypalDisabled && (
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
<Alert variant="destructive">
|
||||||
<p className="text-sm text-muted-foreground">
|
<AlertDescription>{t('checkout.payment_step.paypal_missing_plan')}</AlertDescription>
|
||||||
{t('checkout.payment_step.loading_payment')}
|
</Alert>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ export interface CheckoutPackage {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
description_breakdown?: Array<{
|
||||||
|
title?: string | null;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
gallery_duration_label?: string | null;
|
||||||
|
events?: number | null;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
type: 'endcustomer' | 'reseller';
|
type: 'endcustomer' | 'reseller';
|
||||||
features: string[];
|
features: string[];
|
||||||
limits?: Record<string, unknown>;
|
limits?: Record<string, unknown>;
|
||||||
|
paypal_plan_id?: string | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,4 +44,3 @@ export interface CheckoutWizardContextValue extends CheckoutWizardState {
|
|||||||
resetPaymentState: () => void;
|
resetPaymentState: () => void;
|
||||||
cancelCheckout: () => void;
|
cancelCheckout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ return [
|
|||||||
'platform' => 'Plattform',
|
'platform' => 'Plattform',
|
||||||
'library' => 'Bibliothek',
|
'library' => 'Bibliothek',
|
||||||
'content' => 'Inhalte',
|
'content' => 'Inhalte',
|
||||||
|
'platform_management' => 'Plattformverwaltung',
|
||||||
|
'billing' => 'Billing & Finanzen',
|
||||||
|
'security' => 'Sicherheit',
|
||||||
],
|
],
|
||||||
|
|
||||||
'common' => [
|
'common' => [
|
||||||
@@ -33,6 +36,8 @@ return [
|
|||||||
'settings' => 'Einstellungen',
|
'settings' => 'Einstellungen',
|
||||||
'join' => 'Beitreten',
|
'join' => 'Beitreten',
|
||||||
'unnamed' => 'Ohne Namen',
|
'unnamed' => 'Ohne Namen',
|
||||||
|
'from' => 'Von',
|
||||||
|
'until' => 'Bis',
|
||||||
],
|
],
|
||||||
|
|
||||||
'photos' => [
|
'photos' => [
|
||||||
@@ -213,6 +218,23 @@ return [
|
|||||||
'uploads_per_day' => [
|
'uploads_per_day' => [
|
||||||
'heading' => 'Uploads (14 Tage)',
|
'heading' => 'Uploads (14 Tage)',
|
||||||
],
|
],
|
||||||
|
'credit_alerts' => [
|
||||||
|
'low_balance_label' => 'Mandanten mit niedrigen Credits',
|
||||||
|
'low_balance_desc' => 'Benötigen Betreuung',
|
||||||
|
'monthly_revenue_label' => 'Umsatz (Monat)',
|
||||||
|
'monthly_revenue_desc' => 'Aktueller Monat (:month)',
|
||||||
|
'active_subscriptions_label' => 'Aktive Abos',
|
||||||
|
'active_subscriptions_desc' => 'Laufende Pakete',
|
||||||
|
],
|
||||||
|
'revenue_trend' => [
|
||||||
|
'heading' => 'Monatliche Einnahmen',
|
||||||
|
'series' => 'Umsatz (€)',
|
||||||
|
],
|
||||||
|
'top_tenants_by_revenue' => [
|
||||||
|
'heading' => 'Top‑Mandanten nach Umsatz',
|
||||||
|
'total' => 'Gesamt (€)',
|
||||||
|
'count' => 'Käufe',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'notifications' => [
|
'notifications' => [
|
||||||
@@ -228,6 +250,82 @@ return [
|
|||||||
'contact_email' => 'Kontakt‑E‑Mail',
|
'contact_email' => 'Kontakt‑E‑Mail',
|
||||||
'event_credits_balance' => 'Event‑Credits‑Kontostand',
|
'event_credits_balance' => 'Event‑Credits‑Kontostand',
|
||||||
'features' => 'Funktionen',
|
'features' => 'Funktionen',
|
||||||
|
'total_revenue' => 'Gesamtumsatz',
|
||||||
|
'active_reseller_package' => 'Aktives Reseller-Paket',
|
||||||
|
'remaining_events' => 'Verbleibende Events',
|
||||||
|
'package_expires_at' => 'Ablaufdatum Paket',
|
||||||
|
'is_active' => 'Aktiv',
|
||||||
|
'is_suspended' => 'Suspendiert',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'adjust_credits' => 'Credits anpassen',
|
||||||
|
'adjust_credits_delta' => 'Anzahl Credits (positiv/negativ)',
|
||||||
|
'adjust_credits_delta_hint' => 'Positive Werte fügen Credits hinzu, negative Werte ziehen ab.',
|
||||||
|
'adjust_credits_reason' => 'Interne Notiz',
|
||||||
|
'adjust_credits_success_title' => 'Credits aktualisiert',
|
||||||
|
'adjust_credits_success_body' => 'Die Credits wurden um :delta verändert. Neuer Kontostand: :balance.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'purchase_history' => [
|
||||||
|
'fields' => [
|
||||||
|
'tenant' => 'Mandant',
|
||||||
|
'package' => 'Paket',
|
||||||
|
'credits' => 'Credits',
|
||||||
|
'price' => 'Preis',
|
||||||
|
'currency' => 'Währung',
|
||||||
|
'platform' => 'Plattform',
|
||||||
|
'transaction_id' => 'Transaktions-ID',
|
||||||
|
'purchased_at' => 'Kaufdatum',
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'purchased_at' => 'Zeitraum',
|
||||||
|
'platform' => 'Plattform',
|
||||||
|
'currency' => 'Währung',
|
||||||
|
'tenant' => 'Mandant',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'export' => 'Exportieren',
|
||||||
|
],
|
||||||
|
'platforms' => [
|
||||||
|
'ios' => 'iOS',
|
||||||
|
'android' => 'Android',
|
||||||
|
'web' => 'Web',
|
||||||
|
'manual' => 'Manuell',
|
||||||
|
],
|
||||||
|
'export_success' => 'Export abgeschlossen. :count Einträge exportiert.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'oauth' => [
|
||||||
|
'fields' => [
|
||||||
|
'name' => 'Name',
|
||||||
|
'client_id' => 'Client-ID',
|
||||||
|
'client_secret' => 'Client-Secret',
|
||||||
|
'tenant' => 'Mandant',
|
||||||
|
'redirect_uris' => 'Redirect-URIs',
|
||||||
|
'scopes' => 'Scopes',
|
||||||
|
'is_active' => 'Aktiv',
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'updated_at' => 'Zuletzt geändert',
|
||||||
|
],
|
||||||
|
'hints' => [
|
||||||
|
'client_secret' => 'Leer lassen, um das bestehende Secret zu behalten oder für PKCE-Clients ohne Secret.',
|
||||||
|
'redirect_uris' => 'Eine URL pro Zeile. Die Callback-URL muss exakt übereinstimmen.',
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'is_active' => 'Status',
|
||||||
|
'any' => 'Alle',
|
||||||
|
'active' => 'Aktiv',
|
||||||
|
'inactive' => 'Inaktiv',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'regenerate_secret' => 'Secret neu generieren',
|
||||||
|
],
|
||||||
|
'notifications' => [
|
||||||
|
'secret_regenerated_title' => 'Neues Secret erstellt',
|
||||||
|
'secret_regenerated_body' => 'Speichere das neue Secret sicher: :secret',
|
||||||
|
'created_title' => 'OAuth-Client erstellt',
|
||||||
|
'updated_title' => 'OAuth-Client gespeichert',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ return [
|
|||||||
'platform' => 'Platform',
|
'platform' => 'Platform',
|
||||||
'library' => 'Library',
|
'library' => 'Library',
|
||||||
'content' => 'Content',
|
'content' => 'Content',
|
||||||
|
'platform_management' => 'Platform Management',
|
||||||
|
'billing' => 'Billing & Finance',
|
||||||
|
'security' => 'Security',
|
||||||
],
|
],
|
||||||
|
|
||||||
'common' => [
|
'common' => [
|
||||||
@@ -33,6 +36,8 @@ return [
|
|||||||
'settings' => 'Settings',
|
'settings' => 'Settings',
|
||||||
'join' => 'Join',
|
'join' => 'Join',
|
||||||
'unnamed' => 'Unnamed',
|
'unnamed' => 'Unnamed',
|
||||||
|
'from' => 'From',
|
||||||
|
'until' => 'Until',
|
||||||
],
|
],
|
||||||
|
|
||||||
'photos' => [
|
'photos' => [
|
||||||
@@ -199,6 +204,23 @@ return [
|
|||||||
'uploads_per_day' => [
|
'uploads_per_day' => [
|
||||||
'heading' => 'Uploads (14 days)',
|
'heading' => 'Uploads (14 days)',
|
||||||
],
|
],
|
||||||
|
'credit_alerts' => [
|
||||||
|
'low_balance_label' => 'Tenants with low credits',
|
||||||
|
'low_balance_desc' => 'May require follow-up',
|
||||||
|
'monthly_revenue_label' => 'Revenue (month)',
|
||||||
|
'monthly_revenue_desc' => 'Current month (:month)',
|
||||||
|
'active_subscriptions_label' => 'Active subscriptions',
|
||||||
|
'active_subscriptions_desc' => 'Recurring packages in good standing',
|
||||||
|
],
|
||||||
|
'revenue_trend' => [
|
||||||
|
'heading' => 'Monthly revenue',
|
||||||
|
'series' => 'Revenue (€)',
|
||||||
|
],
|
||||||
|
'top_tenants_by_revenue' => [
|
||||||
|
'heading' => 'Top tenants by revenue',
|
||||||
|
'total' => 'Total (€)',
|
||||||
|
'count' => 'Purchases',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'notifications' => [
|
'notifications' => [
|
||||||
@@ -209,11 +231,87 @@ return [
|
|||||||
|
|
||||||
'tenants' => [
|
'tenants' => [
|
||||||
'fields' => [
|
'fields' => [
|
||||||
'name' => 'Tenant Name',
|
'name' => 'Tenant name',
|
||||||
'slug' => 'Slug',
|
'slug' => 'Slug',
|
||||||
'contact_email' => 'Contact Email',
|
'contact_email' => 'Contact email',
|
||||||
'event_credits_balance' => 'Event Credits Balance',
|
'event_credits_balance' => 'Event credits balance',
|
||||||
'features' => 'Features',
|
'features' => 'Features',
|
||||||
|
'total_revenue' => 'Total revenue',
|
||||||
|
'active_reseller_package' => 'Active reseller package',
|
||||||
|
'remaining_events' => 'Remaining events',
|
||||||
|
'package_expires_at' => 'Package expires at',
|
||||||
|
'is_active' => 'Active',
|
||||||
|
'is_suspended' => 'Suspended',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'adjust_credits' => 'Adjust credits',
|
||||||
|
'adjust_credits_delta' => 'Credit delta (positive/negative)',
|
||||||
|
'adjust_credits_delta_hint' => 'Positive values grant credits, negative values deduct them.',
|
||||||
|
'adjust_credits_reason' => 'Internal note',
|
||||||
|
'adjust_credits_success_title' => 'Credits updated',
|
||||||
|
'adjust_credits_success_body' => 'Credits changed by :delta. New balance: :balance.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'purchase_history' => [
|
||||||
|
'fields' => [
|
||||||
|
'tenant' => 'Tenant',
|
||||||
|
'package' => 'Package',
|
||||||
|
'credits' => 'Credits',
|
||||||
|
'price' => 'Price',
|
||||||
|
'currency' => 'Currency',
|
||||||
|
'platform' => 'Platform',
|
||||||
|
'transaction_id' => 'Transaction ID',
|
||||||
|
'purchased_at' => 'Purchased at',
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'purchased_at' => 'Date range',
|
||||||
|
'platform' => 'Platform',
|
||||||
|
'currency' => 'Currency',
|
||||||
|
'tenant' => 'Tenant',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'export' => 'Export',
|
||||||
|
],
|
||||||
|
'platforms' => [
|
||||||
|
'ios' => 'iOS',
|
||||||
|
'android' => 'Android',
|
||||||
|
'web' => 'Web',
|
||||||
|
'manual' => 'Manual',
|
||||||
|
],
|
||||||
|
'export_success' => 'Export ready. :count rows exported.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'oauth' => [
|
||||||
|
'fields' => [
|
||||||
|
'name' => 'Name',
|
||||||
|
'client_id' => 'Client ID',
|
||||||
|
'client_secret' => 'Client secret',
|
||||||
|
'tenant' => 'Tenant',
|
||||||
|
'redirect_uris' => 'Redirect URIs',
|
||||||
|
'scopes' => 'Scopes',
|
||||||
|
'is_active' => 'Active',
|
||||||
|
'description' => 'Description',
|
||||||
|
'updated_at' => 'Last updated',
|
||||||
|
],
|
||||||
|
'hints' => [
|
||||||
|
'client_secret' => 'Leave blank to keep the current secret or for PKCE/public clients.',
|
||||||
|
'redirect_uris' => 'One URL per line. Must exactly match the callback on the client.',
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'is_active' => 'Status',
|
||||||
|
'any' => 'All',
|
||||||
|
'active' => 'Active',
|
||||||
|
'inactive' => 'Inactive',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'regenerate_secret' => 'Regenerate secret',
|
||||||
|
],
|
||||||
|
'notifications' => [
|
||||||
|
'secret_regenerated_title' => 'New secret generated',
|
||||||
|
'secret_regenerated_body' => 'Store the new secret securely: :secret',
|
||||||
|
'created_title' => 'OAuth client created',
|
||||||
|
'updated_title' => 'OAuth client saved',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->only(['index', 'show', 'destroy'])
|
->only(['index', 'show', 'destroy'])
|
||||||
->parameters(['events' => 'event:slug']);
|
->parameters(['events' => 'event:slug']);
|
||||||
|
|
||||||
Route::middleware('package.check')->group(function () {
|
Route::middleware(['package.check', 'credit.check'])->group(function () {
|
||||||
Route::post('events', [EventController::class, 'store'])->name('tenant.events.store');
|
Route::post('events', [EventController::class, 'store'])->name('tenant.events.store');
|
||||||
Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update');
|
Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update');
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user