diff --git a/app/Console/Commands/SeedDemoSwitcherTenants.php b/app/Console/Commands/SeedDemoSwitcherTenants.php index e4c3b61..11492da 100644 --- a/app/Console/Commands/SeedDemoSwitcherTenants.php +++ b/app/Console/Commands/SeedDemoSwitcherTenants.php @@ -26,7 +26,7 @@ class SeedDemoSwitcherTenants extends Command { protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}'; - protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + reseller profiles)'; + protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + partner profiles)'; public function __construct(private EventStorageManager $eventStorageManager) { @@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command $slugs = [ 'starter' => 'Starter', 'standard' => 'Standard', - 's-small-reseller' => 'Reseller S', + 's-small-reseller' => 'Partner Start', ]; $packages = []; @@ -232,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command private function seedResellerActive(array $packages, array $eventTypes): void { + $eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages); $tenant = $this->upsertTenant( slug: 'demo-reseller-active', - name: 'Demo Reseller Active', - contactEmail: 'reseller-active@demo.fotospiel', + name: 'Demo Partner Active', + contactEmail: 'partner-active@demo.fotospiel', attributes: [ 'subscription_tier' => 'reseller', 'subscription_status' => 'active', ], ); - $this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel'); + $this->upsertAdmin($tenant, 'partner-active@demo.fotospiel'); TenantPackage::updateOrCreate( ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], @@ -279,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command foreach ($events as $index => $config) { $event = $this->upsertEvent( tenant: $tenant, - package: $packages['standard'], + package: $eventPackage, eventType: $config['type'], attributes: [ 'name' => $config['name'], @@ -296,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command private function seedResellerFull(array $packages, array $eventTypes): void { + $eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages); $tenant = $this->upsertTenant( slug: 'demo-reseller-full', - name: 'Demo Reseller Voll', - contactEmail: 'reseller-full@demo.fotospiel', + name: 'Demo Partner Voll', + contactEmail: 'partner-full@demo.fotospiel', attributes: [ 'subscription_tier' => 'reseller', 'subscription_status' => 'active', ], ); - $this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel'); + $this->upsertAdmin($tenant, 'partner-full@demo.fotospiel'); TenantPackage::updateOrCreate( ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], @@ -330,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command foreach ($eventConfigs as $index => $config) { $event = $this->upsertEvent( tenant: $tenant, - package: $packages['standard'], + package: $eventPackage, eventType: $config['type'], attributes: [ 'name' => $config['name'], @@ -435,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command return $event; } + private function resolveIncludedPackage(Package $resellerPackage, array $packages): Package + { + $includedSlug = $resellerPackage->included_package_slug; + + if ($includedSlug && isset($packages[$includedSlug])) { + return $packages[$includedSlug]; + } + + $fallback = $packages['starter'] ?? $packages['standard'] ?? null; + + return $fallback ?? $resellerPackage; + } + private function fallbackEventType(): ?EventType { $fallback = EventType::first(); diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php index ecbcfb2..66dc41d 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -277,13 +277,13 @@ class PackageController extends Controller 'purchased_at' => now(), ]); } else { - // Reseller subscription + // Partner / reseller Event-Kontingent package \App\Models\TenantPackage::create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'price' => $package->price, 'purchased_at' => now(), - 'expires_at' => now()->addYear(), + 'expires_at' => null, 'active' => true, ]); } diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 058f588..05b64d1 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -99,6 +99,9 @@ class EventController extends Controller $requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null; unset($validated['package_id']); + $requestedServiceSlug = $request->input('service_package_slug'); + $requestedServiceSlug = is_string($requestedServiceSlug) && $requestedServiceSlug !== '' ? $requestedServiceSlug : null; + unset($validated['service_package_slug']); $tenantPackage = $tenant->tenantPackages() ->with('package') @@ -116,6 +119,18 @@ class EventController extends Controller $package = $this->resolveOwnerPackage(); } + $billingTenantPackage = null; + if (! $package) { + $billingTenantPackage = $requestedServiceSlug + ? $tenant->getActiveResellerPackageFor($requestedServiceSlug) + : $tenant->getActiveResellerPackage(); + + if ($billingTenantPackage && $billingTenantPackage->package) { + $package = $billingTenantPackage->package; + $requestedServiceSlug = $requestedServiceSlug ?: $package->included_package_slug; + } + } + if (! $package && $tenantPackage) { $package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id); } @@ -126,6 +141,11 @@ class EventController extends Controller ]); } + $billingIsReseller = $package->isReseller(); + $eventServicePackage = $billingIsReseller + ? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug) + : $package; + $requiresWaiver = $package->isEndcustomer(); $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; $existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null; @@ -161,8 +181,8 @@ class EventController extends Controller unset($eventData['features']); } - $settings['branding_allowed'] = $package->branding_allowed !== false; - $settings['watermark_allowed'] = $package->watermark_allowed !== false; + $settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false; + $settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false; $eventData['settings'] = $settings; @@ -190,21 +210,23 @@ class EventController extends Controller $eventData = Arr::only($eventData, $allowed); - $event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) { + $event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) { $event = Event::create($eventData); EventPackage::create([ 'event_id' => $event->id, - 'package_id' => $package->id, - 'purchased_price' => $package->price, + 'package_id' => $eventServicePackage->id, + 'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price, 'purchased_at' => now(), - 'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, + 'gallery_expires_at' => $eventServicePackage->gallery_days + ? now()->addDays($eventServicePackage->gallery_days) + : null, ]); - if ($package->isReseller() && ! $isSuperAdmin) { + if ($billingIsReseller && ! $isSuperAdmin) { $note = sprintf('Event #%d created (%s)', $event->id, $event->name); - if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { + if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) { throw new HttpException(402, 'Insufficient package allowance.'); } } @@ -227,6 +249,47 @@ class EventController extends Controller ], 201); } + private function resolveResellerDefaultEventPackage(): Package + { + return $this->resolveResellerEventPackageForSlug('standard'); + } + + private function resolveResellerEventPackageForSlug(?string $slug): Package + { + if (is_string($slug) && $slug !== '') { + $match = Package::query() + ->where('type', 'endcustomer') + ->where('slug', $slug) + ->first(); + + if ($match) { + return $match; + } + } + + $default = Package::query() + ->where('type', 'endcustomer') + ->where('slug', 'standard') + ->first(); + + if ($default) { + return $default; + } + + $fallback = Package::query() + ->where('type', 'endcustomer') + ->orderBy('price') + ->first(); + + if (! $fallback) { + throw ValidationException::withMessages([ + 'package_id' => __('Aktuell ist kein Endkunden-Paket verfügbar. Bitte kontaktiere den Support.'), + ]); + } + + return $fallback; + } + private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase { return PackagePurchase::query() diff --git a/app/Http/Controllers/Api/TenantPackageController.php b/app/Http/Controllers/Api/TenantPackageController.php index a905994..36cab48 100644 --- a/app/Http/Controllers/Api/TenantPackageController.php +++ b/app/Http/Controllers/Api/TenantPackageController.php @@ -60,6 +60,7 @@ class TenantPackageController extends Controller $pkg?->limits ?? [], $this->buildUsageSnapshot($eventPackage), [ + 'included_package_slug' => $pkg?->included_package_slug, 'branding_allowed' => $pkg?->branding_allowed, 'watermark_allowed' => $pkg?->watermark_allowed, 'features' => $pkg?->features ?? [], diff --git a/app/Http/Middleware/CreditCheckMiddleware.php b/app/Http/Middleware/CreditCheckMiddleware.php index e9a5c5e..3c8f715 100644 --- a/app/Http/Middleware/CreditCheckMiddleware.php +++ b/app/Http/Middleware/CreditCheckMiddleware.php @@ -28,7 +28,12 @@ class CreditCheckMiddleware } if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) { - $violation = $this->limitEvaluator->assessEventCreation($tenant); + $includedSlug = $request->input('service_package_slug'); + + $violation = $this->limitEvaluator->assessEventCreation( + $tenant, + is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null + ); if ($violation !== null) { return ApiError::response( diff --git a/app/Http/Middleware/PackageMiddleware.php b/app/Http/Middleware/PackageMiddleware.php index 093e700..45968ae 100644 --- a/app/Http/Middleware/PackageMiddleware.php +++ b/app/Http/Middleware/PackageMiddleware.php @@ -73,7 +73,12 @@ class PackageMiddleware private function detectViolation(Request $request, Tenant $tenant): ?array { if ($request->routeIs('api.v1.tenant.events.store')) { - return $this->limitEvaluator->assessEventCreation($tenant); + $includedSlug = $request->input('service_package_slug'); + + return $this->limitEvaluator->assessEventCreation( + $tenant, + is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null + ); } if ($request->routeIs('api.v1.tenant.events.photos.store')) { diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index c76fe09..f658325 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -31,6 +31,12 @@ class EventStoreRequest extends FormRequest 'location' => ['nullable', 'string', 'max:255'], 'event_type_id' => ['required', 'exists:event_types,id'], 'package_id' => ['nullable', 'integer', 'exists:packages,id'], + 'service_package_slug' => [ + 'nullable', + 'string', + 'max:64', + Rule::exists('packages', 'slug')->where('type', 'endcustomer'), + ], 'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'], 'public_url' => ['nullable', 'url', 'max:500'], 'custom_domain' => ['nullable', 'string', 'max:255'], diff --git a/app/Models/Package.php b/app/Models/Package.php index e839764..742ae69 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -19,6 +19,7 @@ class Package extends Model 'name_translations', 'slug', 'type', + 'included_package_slug', 'price', 'max_photos', 'max_guests', diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 9cae9f2..2cb2e8d 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -100,7 +100,14 @@ class Tenant extends Model public function activeResellerPackage(): HasOne { - return $this->hasOne(TenantPackage::class)->where('active', true); + return $this->hasOne(TenantPackage::class) + ->where('active', true) + ->where(function ($query) { + $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) + ->orderBy('purchased_at') + ->orderBy('id'); } public function notificationLogs(): HasMany @@ -151,6 +158,13 @@ class Tenant extends Model return false; } + public function hasEventAllowanceFor(?string $includedPackageSlug): bool + { + $package = $this->getActiveResellerPackageFor($includedPackageSlug); + + return $package !== null && $package->canCreateEvent(); + } + public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool { $package = $this->getActiveResellerPackage(); @@ -183,13 +197,68 @@ class Tenant extends Model return false; } + public function consumeEventAllowanceFor(?string $includedPackageSlug, int $amount = 1, string $reason = 'event.create', ?string $note = null): bool + { + $package = $this->getActiveResellerPackageFor($includedPackageSlug); + + if ($package && $package->canCreateEvent()) { + $previousUsed = (int) $package->used_events; + $package->increment('used_events', $amount); + $package->refresh(); + + app(\App\Services\Packages\TenantUsageTracker::class)->recordEventUsage( + $package, + $previousUsed, + $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; + } + + Log::warning('Event allowance missing for tenant', [ + 'tenant_id' => $this->id, + 'reason' => $reason, + 'included_package_slug' => $includedPackageSlug, + ]); + + return false; + } + public function getActiveResellerPackage(): ?TenantPackage { - return $this->activeResellerPackage() - ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) + return $this->activeResellerPackage()->with('package')->first(); + } + + public function getActiveResellerPackageFor(?string $includedPackageSlug): ?TenantPackage + { + $query = $this->tenantPackages() + ->with('package') ->where('active', true) - ->orderByDesc('expires_at') - ->first(); + ->where(function ($query) { + $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) + ->orderBy('purchased_at') + ->orderBy('id'); + + if (is_string($includedPackageSlug) && $includedPackageSlug !== '') { + $query->whereHas('package', function ($query) use ($includedPackageSlug) { + $query->where('included_package_slug', $includedPackageSlug); + + if ($includedPackageSlug === 'standard') { + $query->orWhereNull('included_package_slug'); + } + }); + } + + return $query->first(); } public function activeSubscription(): Attribute diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php index 77cab1a..012e380 100644 --- a/app/Models/TenantPackage.php +++ b/app/Models/TenantPackage.php @@ -66,18 +66,30 @@ class TenantPackage extends Model return false; } - $maxEvents = $this->package->max_events_per_year ?? 0; + $maxEvents = $this->package->max_events_per_year; + + if ($maxEvents === null) { + return true; + } + + $maxEvents = max(0, (int) $maxEvents); return $this->used_events < $maxEvents; } - public function getRemainingEventsAttribute(): int + public function getRemainingEventsAttribute(): ?int { if (! $this->package->isReseller()) { return 0; } - $max = $this->package->max_events_per_year ?? 0; + $max = $this->package->max_events_per_year; + + if ($max === null) { + return null; + } + + $max = max(0, (int) $max); return max(0, $max - $this->used_events); } @@ -94,9 +106,7 @@ class TenantPackage extends Model $package = $tenantPackage->package; if ($package && $package->isReseller()) { - if (! $tenantPackage->expires_at) { - $tenantPackage->expires_at = now()->addYear(); - } + // Reseller packages represent prepaid Event-Kontingente and should not expire by default. } elseif (! $tenantPackage->expires_at) { $tenantPackage->expires_at = now()->addYear(); } diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index 3f3ad43..d0482ad 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -94,18 +94,34 @@ class CheckoutAssignmentService ] ); - $tenantPackage = TenantPackage::updateOrCreate( - [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - ], - [ - 'price' => round($price, 2), - 'active' => true, - 'purchased_at' => now(), - 'expires_at' => $this->resolveExpiry($package, $tenant), - ] - ); + if ($package->type === 'reseller') { + $tenantPackage = null; + + if ($purchase->wasRecentlyCreated) { + $tenantPackage = TenantPackage::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'price' => round($price, 2), + 'active' => true, + 'purchased_at' => now(), + 'expires_at' => null, + 'used_events' => 0, + ]); + } + } else { + $tenantPackage = TenantPackage::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ], + [ + 'price' => round($price, 2), + 'active' => true, + 'purchased_at' => now(), + 'expires_at' => $this->resolveExpiry($package, $tenant), + ] + ); + } if ($package->type !== 'reseller') { $tenant->forceFill([ @@ -188,11 +204,7 @@ class CheckoutAssignmentService protected function resolveExpiry(Package $package, Tenant $tenant) { if ($package->type === 'reseller') { - $hasActive = TenantPackage::where('tenant_id', $tenant->id) - ->where('active', true) - ->exists(); - - return $hasActive ? now()->addYear() : now()->addDays(14); + return null; } return now()->addYear(); diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index 64c449b..0365a22 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -11,7 +11,7 @@ class PackageLimitEvaluator { public function __construct(private readonly TenantUsageService $tenantUsageService) {} - public function assessEventCreation(Tenant $tenant): ?array + public function assessEventCreation(Tenant $tenant, ?string $includedPackageSlug = null): ?array { $hasEndcustomerPackage = $tenant->tenantPackages() ->where('active', true) @@ -22,17 +22,66 @@ class PackageLimitEvaluator return null; } - if ($tenant->hasEventAllowance()) { + if ($tenant->hasEventAllowanceFor($includedPackageSlug)) { return null; } - $package = $tenant->getActiveResellerPackage(); + $package = $tenant->getActiveResellerPackageFor($includedPackageSlug); if (! $package) { + if ($includedPackageSlug) { + $hasAnyActive = $tenant->tenantPackages() + ->where('active', true) + ->where(function ($query) { + $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) + ->exists(); + + if ($hasAnyActive) { + return [ + 'code' => 'event_tier_unavailable', + 'title' => __('api.packages.event_tier_unavailable.title'), + 'message' => __('api.packages.event_tier_unavailable.message'), + 'status' => 402, + 'meta' => [ + 'scope' => 'events', + 'requested_tier' => $includedPackageSlug, + ], + ]; + } + } + + $latestResellerPackage = $tenant->tenantPackages() + ->with('package') + ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) + ->orderByDesc('purchased_at') + ->orderByDesc('id') + ->first(); + + if ($latestResellerPackage && $latestResellerPackage->package) { + $limit = $latestResellerPackage->package->max_events_per_year ?? 0; + + return [ + 'code' => 'event_limit_exceeded', + 'title' => __('api.packages.event_limit_exceeded.title'), + 'message' => __('api.packages.event_limit_exceeded.message'), + 'status' => 402, + 'meta' => [ + 'scope' => 'events', + 'used' => (int) $latestResellerPackage->used_events, + 'limit' => $limit, + 'remaining' => max(0, $limit - $latestResellerPackage->used_events), + 'tenant_package_id' => $latestResellerPackage->id, + 'package_id' => $latestResellerPackage->package_id, + ], + ]; + } + return [ 'code' => 'event_limit_missing', - 'title' => 'No package assigned', - 'message' => 'Assign a package or addon to create events.', + 'title' => __('api.packages.event_limit_missing.title'), + 'message' => __('api.packages.event_limit_missing.message'), 'status' => 402, 'meta' => [ 'scope' => 'events', @@ -49,8 +98,8 @@ class PackageLimitEvaluator return [ 'code' => 'event_limit_exceeded', - 'title' => 'Event quota reached', - 'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.', + 'title' => __('api.packages.event_limit_exceeded.title'), + 'message' => __('api.packages.event_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'events', @@ -74,8 +123,8 @@ class PackageLimitEvaluator if (! $event) { return [ 'code' => 'event_not_found', - 'title' => 'Event not accessible', - 'message' => 'The selected event could not be found or belongs to another tenant.', + 'title' => __('api.packages.event_not_found.title'), + 'message' => __('api.packages.event_not_found.message'), 'status' => 404, 'meta' => [ 'scope' => 'photos', @@ -87,8 +136,8 @@ class PackageLimitEvaluator if (! $eventPackage || ! $eventPackage->package) { return [ 'code' => 'event_package_missing', - 'title' => 'Event package missing', - 'message' => 'No package is attached to this event. Assign a package to enable uploads.', + 'title' => __('api.packages.event_package_missing.title'), + 'message' => __('api.packages.event_package_missing.message'), 'status' => 409, 'meta' => [ 'scope' => 'photos', @@ -102,8 +151,8 @@ class PackageLimitEvaluator if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) { return [ 'code' => 'photo_limit_exceeded', - 'title' => 'Photo upload limit reached', - 'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.', + 'title' => __('api.packages.photo_limit_exceeded.title'), + 'message' => __('api.packages.photo_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'photos', @@ -122,8 +171,8 @@ class PackageLimitEvaluator if ($eventPackage->used_photos >= $tenantPhotoLimit) { return [ 'code' => 'tenant_photo_limit_exceeded', - 'title' => 'Tenant photo limit reached', - 'message' => 'This tenant has reached its photo allowance for the event.', + 'title' => __('api.packages.tenant_photo_limit_exceeded.title'), + 'message' => __('api.packages.tenant_photo_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'photos', @@ -146,8 +195,8 @@ class PackageLimitEvaluator if ($projectedBytes >= $storageLimitBytes) { return [ 'code' => 'tenant_storage_limit_exceeded', - 'title' => 'Tenant storage limit reached', - 'message' => 'This tenant has reached its storage allowance.', + 'title' => __('api.packages.tenant_storage_limit_exceeded.title'), + 'message' => __('api.packages.tenant_storage_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'storage', diff --git a/app/Services/Packages/TenantUsageTracker.php b/app/Services/Packages/TenantUsageTracker.php index 21dffc8..d9fe2e2 100644 --- a/app/Services/Packages/TenantUsageTracker.php +++ b/app/Services/Packages/TenantUsageTracker.php @@ -4,7 +4,6 @@ namespace App\Services\Packages; use App\Events\Packages\TenantPackageEventLimitReached; use App\Events\Packages\TenantPackageEventThresholdReached; -use App\Models\Tenant; use App\Models\TenantPackage; use Illuminate\Contracts\Events\Dispatcher; @@ -63,6 +62,12 @@ class TenantUsageTracker } $this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit)); + + if ($tenantPackage->active) { + $tenantPackage->forceFill([ + 'active' => false, + ])->save(); + } } } } diff --git a/app/Support/Concerns/PresentsPackages.php b/app/Support/Concerns/PresentsPackages.php index ae75ece..0235317 100644 --- a/app/Support/Concerns/PresentsPackages.php +++ b/app/Support/Concerns/PresentsPackages.php @@ -43,6 +43,7 @@ trait PresentsPackages 'name' => $name, 'slug' => $package->slug, 'type' => $package->type, + 'included_package_slug' => $package->included_package_slug, 'price' => $package->price, 'paddle_product_id' => $package->paddle_product_id, 'paddle_price_id' => $package->paddle_price_id, diff --git a/database/migrations/2026_01_15_113309_add_included_package_slug_to_packages_table.php b/database/migrations/2026_01_15_113309_add_included_package_slug_to_packages_table.php new file mode 100644 index 0000000..10a0867 --- /dev/null +++ b/database/migrations/2026_01_15_113309_add_included_package_slug_to_packages_table.php @@ -0,0 +1,34 @@ +string('included_package_slug')->nullable()->after('type'); + $table->index(['type', 'included_package_slug']); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('packages', function (Blueprint $table) { + if (Schema::hasColumn('packages', 'included_package_slug')) { + $table->dropIndex(['type', 'included_package_slug']); + $table->dropColumn('included_package_slug'); + } + }); + } +}; diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index f5ceefc..d7094a2 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -116,111 +116,149 @@ TEXT, ], [ 'slug' => 's-small-reseller', - 'name' => 'Reseller S', + 'name' => 'Partner Start', 'name_translations' => [ - 'de' => 'Reseller S', - 'en' => 'Reseller S', + 'de' => 'Partner Start', + 'en' => 'Partner Start', ], 'type' => PackageType::RESELLER, + 'included_package_slug' => 'starter', 'price' => 149.00, - 'max_photos' => 1000, + 'max_photos' => null, 'max_guests' => null, - 'gallery_days' => 30, + 'gallery_days' => null, 'max_tasks' => null, 'watermark_allowed' => true, 'branding_allowed' => true, 'max_events_per_year' => 5, - 'expires_after' => now()->copy()->addYear(), + 'expires_after' => null, 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'], 'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y', 'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy', 'description' => <<<'TEXT' -Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung – Branding-Optionen inklusive. +Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, 'description_translations' => [ - 'de' => 'Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung – Branding-Optionen inklusive.', - 'en' => 'Perfect for photographers or planners getting started with Fotospiel. Includes {{max_events_per_year}} events per year with the standard feature set—branding options included.', + 'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.', + 'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Starter level. Recommended to use within 24 months.', ], 'description_table' => [ - ['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], - ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], - ['title' => 'Branding', 'value' => 'Logo & Farben pro Event'], + ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Inklusive Event-Level', 'value' => 'Starter'], + ['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'], ], ], [ 'slug' => 'm-medium-reseller', - 'name' => 'Reseller M', + 'name' => 'Partner Standard', 'name_translations' => [ - 'de' => 'Reseller M', - 'en' => 'Reseller M', + 'de' => 'Partner Standard', + 'en' => 'Partner Standard', ], 'type' => PackageType::RESELLER, + 'included_package_slug' => 'standard', 'price' => 349.00, - 'max_photos' => 1500, + 'max_photos' => null, 'max_guests' => null, - 'gallery_days' => 60, + 'gallery_days' => null, 'max_tasks' => null, 'watermark_allowed' => true, 'branding_allowed' => true, 'max_events_per_year' => 15, - 'expires_after' => now()->copy()->addYear(), + 'expires_after' => null, 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'], 'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q', 'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v', 'description' => <<<'TEXT' -Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive. +Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, 'description_translations' => [ - 'de' => 'Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive.', - 'en' => 'Designed for professionals who regularly support weddings, corporate events or private parties. {{max_events_per_year}} events per year with branding options, extended gallery runtime and reporting included.', + 'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.', + 'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.', ], 'description_table' => [ - ['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], - ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], - ['title' => 'Reporting', 'value' => 'Erweiterte Auswertungen'], + ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Inklusive Event-Level', 'value' => 'Standard'], + ['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'], ], ], [ 'slug' => 'l-large-reseller', - 'name' => 'Reseller L', + 'name' => 'Partner Premium', 'name_translations' => [ - 'de' => 'Reseller L', - 'en' => 'Reseller L', + 'de' => 'Partner Premium', + 'en' => 'Partner Premium', ], 'type' => PackageType::RESELLER, - 'price' => 699.00, - 'max_photos' => 3000, + 'included_package_slug' => 'pro', + 'price' => 1999.00, + 'max_photos' => null, 'max_guests' => null, - 'gallery_days' => 90, + 'gallery_days' => null, 'max_tasks' => null, 'watermark_allowed' => false, 'branding_allowed' => true, - 'max_events_per_year' => 40, - 'expires_after' => now()->copy()->addYear(), + 'max_events_per_year' => 35, + 'expires_after' => null, 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'], 'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz', 'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z', 'description' => <<<'TEXT' -Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität. +Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, 'description_translations' => [ - 'de' => 'Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität.', - 'en' => 'Ideal for agencies, photographers or event providers with a packed calendar. {{max_events_per_year}} events included, white-label branding and all premium features for maximum flexibility.', + 'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.', + 'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.', ], 'description_table' => [ - ['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], - ['title' => 'Branding', 'value' => 'White-Label & eigene Domains'], - ['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Features'], + ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Inklusive Event-Level', 'value' => 'Premium'], + ['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'], + ], + ], + [ + 'slug' => 'partner-premium-5', + 'name' => 'Partner Premium-Kontingent (5 Events)', + 'name_translations' => [ + 'de' => 'Partner Premium-Kontingent (5 Events)', + 'en' => 'Partner Premium kontingent (5 events)', + ], + 'type' => PackageType::RESELLER, + 'included_package_slug' => 'pro', + 'price' => 549.00, + 'max_photos' => null, + 'max_guests' => null, + 'gallery_days' => null, + 'max_tasks' => null, + 'watermark_allowed' => false, + 'branding_allowed' => true, + 'max_events_per_year' => 5, + 'expires_after' => null, + 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'], + 'paddle_product_id' => 'pro_01kf16ttp0fph79j59x0z1cdqc', + 'paddle_price_id' => 'pri_01kf16v0v2z4hse5cxq5wnah4b', + 'description' => <<<'TEXT' +Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. +TEXT, + 'description_translations' => [ + 'de' => 'Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.', + 'en' => 'Premium Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.', + ], + 'description_table' => [ + ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Inklusive Event-Level', 'value' => 'Premium'], + ['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'], ], ], [ 'slug' => 'studio-annual', - 'name' => 'Studio Jahrespaket', + 'name' => 'Partner Jahreskontingent (24 Events)', 'name_translations' => [ - 'de' => 'Studio Jahrespaket', - 'en' => 'Studio Annual', + 'de' => 'Partner Jahreskontingent (24 Events)', + 'en' => 'Partner annual kontingent (24 events)', ], 'type' => PackageType::RESELLER, + 'included_package_slug' => 'standard', 'price' => 1299.00, 'max_photos' => null, 'max_guests' => null, @@ -230,42 +268,20 @@ TEXT, 'branding_allowed' => false, 'max_events_per_year' => 24, 'expires_after' => null, - 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding'], + 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'], 'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb', 'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06', - 'description' => null, - 'description_translations' => null, - 'description_table' => [], - ], - [ - 'slug' => 'enterprise-unlimited', - 'name' => 'Enterprise / Unlimited', - 'name_translations' => [ - 'de' => 'Enterprise / Unlimited', - 'en' => 'Enterprise / Unlimited', - ], - 'type' => PackageType::RESELLER, - 'price' => 1999.00, - 'max_photos' => null, - 'max_guests' => null, - 'gallery_days' => null, - 'max_tasks' => null, - 'watermark_allowed' => false, - 'branding_allowed' => true, - 'max_events_per_year' => null, - 'expires_after' => now()->copy()->addYear(), - 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow', 'unlimited_sharing'], 'description' => <<<'TEXT' -Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding – alles individuell anpassbar, inklusive persönlicher Betreuung. +Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, 'description_translations' => [ - 'de' => 'Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding – alles individuell anpassbar, inklusive persönlicher Betreuung.', - 'en' => 'The all-round package for enterprises and agencies needing maximum flexibility. {{max_events_per_year}} events, full white-label branding, your own subdomain or app branding—fully customisable with dedicated support.', + 'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.', + 'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.', ], 'description_table' => [ - ['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], - ['title' => 'Branding', 'value' => 'Eigene Subdomain oder App'], - ['title' => 'Support', 'value' => 'Persönliche Betreuung'], + ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Inklusive Event-Level', 'value' => 'Standard'], + ['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'], ], ], ]; @@ -279,5 +295,7 @@ TEXT, ]) ); } + + Package::where('slug', 'enterprise-unlimited')->delete(); } } diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 533b5e9..1c76ac3 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -91,18 +91,18 @@ "title": "Unsere Packages", "price": "Preis", "features": "Features", - "subscription_annual": "Jährliches Abonnement", + "subscription_annual": "Event-Kontingent", "auto_renew": "automatische Verlängerung", "cancel_anytime": "kündbar jederzeit", "trial_start": "Kostenloser Trial für :days Tage", - "reseller_benefits": "Vorteile für Reseller", + "reseller_benefits": "Vorteile für Partner / Agenturen", "unlimited_events": "Unbegrenzte Events", "custom_branding": "Benutzerdefiniertes Branding", "available": "Verfügbar", "not_available": "Nicht verfügbar", "standard_support": "Standard-Support", "priority_support": "Priorisierter Support", - "cancel_link": "Abo kündigen: :link", + "cancel_link": "Paket verwalten: :link", "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_secondary": "Teste den kompletten Gäste-Flow in unserer Live-Demo – kein Login, kein App-Store.", @@ -110,21 +110,24 @@ "cta_explore": "Pakete entdecken", "cta_explore_highlight": "Lieblingspaket sichern", "tab_endcustomer": "Endkunden", - "tab_reseller": "Reseller & Agenturen", + "tab_reseller": "Partner / Agentur", "section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)", - "section_reseller": "Packages für Reseller (Jährliches Abo)", + "section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)", "free": "Kostenlos", "one_time": "Einmalkauf", - "subscription": "Abo", + "subscription": "Event-Kontingent", "year": "Jahr", "max_photos": "Fotos", "max_guests": "Gäste", "gallery_days": "Tage Galerie", - "max_events_year": "Events/Jahr", + "max_events_year": "Events enthalten", + "included_package_label": "Inklusive Event-Level", + "recommended_usage_label": "Empfehlung", + "recommended_usage_window": "Empfohlen innerhalb von 24 Monaten zu nutzen.", "buy_now": "Jetzt kaufen", - "subscribe_now": "Jetzt abonnieren", + "subscribe_now": "Event-Kontingent kaufen", "register_buy": "Registrieren und kaufen", - "register_subscribe": "Registrieren und abonnieren", + "register_subscribe": "Registrieren und kaufen", "faq_title": "Häufige Fragen zu Packages", "faq_lead": "Antworten auf die wichtigsten Fragen – mehr Details findest du im Guide „So funktioniert’s“.", "faq_q1": "Was ist ein Package?", @@ -153,7 +156,7 @@ "feature_limited_sharing": "Begrenztes Teilen", "feature_no_branding": "Kein Branding", "feature_0": "Basis-Feature", - "feature_reseller_dashboard": "Reseller-Dashboard", + "feature_reseller_dashboard": "Partner-Dashboard", "feature_custom_branding": "Benutzerdefiniertes Branding", "feature_advanced_reporting": "Erweiterte Berichterstattung", "badge_most_popular": "Beliebteste Wahl", @@ -161,6 +164,7 @@ "badge_starter": "Perfekt für den Start", "billing_per_event": "pro Event", "billing_per_year": "pro Jahr", + "billing_per_kontingent": "pro Kontingent", "more_features": "+{{count}} weitere Features", "feature_overview": "Feature-Überblick", "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.", @@ -173,7 +177,7 @@ "tasks": "Aufgaben", "gallery": "Galerie", "branding": "Branding", - "events_per_year": "Events pro Jahr" + "events_per_year": "Events enthalten" }, "more_details_tab": "Mehr Details", "quick_facts": "Schnelle Fakten", @@ -185,7 +189,7 @@ "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_resellers": "Für Reseller", + "for_resellers": "Für Partner / Agenturen", "view_details": "Details ansehen", "details_show": "Details anzeigen", "comparison_title": "Packages vergleichen", @@ -199,14 +203,14 @@ "watermark_label": "Wasserzeichen", "no_watermark": "Kein Wasserzeichen", "max_tenants": "Max. Tenants", - "max_events": "Max. Events/Jahr", + "max_events": "Events enthalten", "faq_free": "Was ist das Free Package?", "faq_upgrade": "Kann ich upgraden?", - "faq_reseller": "Was für Reseller?", + "faq_reseller": "Was für Partner / Agenturen?", "faq_payment": "Zahlung sicher?", "faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.", "faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.", - "faq_reseller_desc": "Reseller-Packages sind jährliche Abos für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.", + "faq_reseller_desc": "Partner-Pakete sind Event-Kontingente für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.", "faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Paddle abgewickelt. Ihre Daten sind GDPR-konform geschützt.", "testimonials": { "anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.", @@ -352,7 +356,7 @@ "purchase_complete_desc": "Melden Sie sich an, um fortzufahren.", "login": "Anmelden", "no_account": "Kein Konto? Registrieren", - "manage_subscription": "Abo verwalten", + "manage_subscription": "Kontingent verwalten", "stripe_dashboard": "Stripe-Dashboard", "trial_activated": "Trial aktiviert für 14 Tage!" }, @@ -488,7 +492,7 @@ "summary_title": "Ihre Bestellung", "package_label": "Ausgewähltes Paket", "billing_type_one_time": "Einmalkauf (pro Event)", - "billing_type_subscription": "Abo (wiederkehrend)", + "billing_type_subscription": "Einmalkauf (Kontingent)", "legal_links_intro": "Details zur Belehrung:", "link_terms": "AGB", "link_privacy": "Datenschutzerklärung", @@ -497,7 +501,7 @@ "checkbox_terms_error": "Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.", "checkbox_digital_content_label": "Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.", "checkbox_digital_content_error": "Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.", - "hint_subscription_withdrawal": "Bei Abonnements haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.", + "hint_subscription_withdrawal": "Bei Einmalkäufen haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.", "open_withdrawal": "Widerrufsbelehrung anzeigen", "modal_description": "So informieren wir über das Widerrufsrecht. Der volle Text gilt für deinen Kauf.", "modal_loading": "Widerrufsbelehrung wird geladen…", @@ -770,7 +774,7 @@ "timeline": [ { "title": "Event vorbereiten", - "body": "Account registrieren, Paket wählen und Branding setzen. Abos laufen über Paddle, Mobile-Apps über RevenueCat.", + "body": "Account registrieren, Paket wählen und Branding setzen. Kontingente laufen über Paddle, Mobile-Apps über RevenueCat.", "tips": [ "Testevent anlegen, um Upload-Flow vorab zu prüfen", "Trauzeug:innen oder Kolleg:innen als Co-Hosts einladen" diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 8650fca..668eb24 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -82,14 +82,14 @@ "packages": { "title": "Our Packages", "features": "Features", - "subscription_annual": "Annual Subscription", + "subscription_annual": "Event kontingent", "auto_renew": "auto-renew", "cancel_anytime": "cancel anytime", "trial_start": "Free Trial for :days days", - "reseller_benefits": "Benefits for Resellers", + "reseller_benefits": "Benefits for Partner / Agencies", "unlimited_events": "Unlimited Events", "priority_support": "Priority Support", - "cancel_link": "Cancel Subscription: :link", + "cancel_link": "Manage package: :link", "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_secondary": "Experience the full guest flow in our live demo – no login, no install.", @@ -97,21 +97,24 @@ "cta_explore": "Discover Packages", "cta_explore_highlight": "Explore top packages", "tab_endcustomer": "End Customers", - "tab_reseller": "Resellers & Agencies", + "tab_reseller": "Partner / Agency", "section_endcustomer": "Packages for End Customers (One-time purchase per event)", - "section_reseller": "Packages for Resellers (Annual Subscription)", + "section_reseller": "Packages for Partner / Agencies (Event kontingent)", "free": "Free", "one_time": "One-time purchase", - "subscription": "Subscription", + "subscription": "Event kontingent", "year": "Year", "max_photos": "Photos", "max_guests": "Guests", "gallery_days": "Gallery Days", - "max_events_year": "Events/Year", + "max_events_year": "Events included", + "included_package_label": "Included event tier", + "recommended_usage_label": "Recommendation", + "recommended_usage_window": "Recommended to use within 24 months.", "buy_now": "Buy Now", - "subscribe_now": "Subscribe Now", + "subscribe_now": "Buy event kontingent", "register_buy": "Register and Buy", - "register_subscribe": "Register and Subscribe", + "register_subscribe": "Register and buy", "faq_title": "Frequently Asked Questions about Packages", "faq_lead": "Quick answers to the essentials – check “How it works” for the full deep dive.", "faq_q1": "What is a Package?", @@ -140,7 +143,7 @@ "feature_limited_sharing": "Limited Sharing", "feature_no_branding": "No Branding", "feature_0": "Basic Feature", - "feature_reseller_dashboard": "Reseller Dashboard", + "feature_reseller_dashboard": "Partner dashboard", "feature_custom_branding": "Custom Branding", "feature_advanced_reporting": "Advanced Reporting", "badge_most_popular": "Most Popular", @@ -148,6 +151,7 @@ "badge_starter": "Perfect Starter", "billing_per_event": "per event", "billing_per_year": "per year", + "billing_per_kontingent": "per bundle", "more_features": "+{{count}} more features", "feature_overview": "Feature overview", "order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.", @@ -159,7 +163,7 @@ "tasks": "Challenges", "gallery": "Gallery", "branding": "Branding", - "events_per_year": "Events per year" + "events_per_year": "Events included" }, "more_details_tab": "More Details", "quick_facts": "Quick Facts", @@ -171,7 +175,7 @@ "limits_label": "Limits & Capacity", "limits_label_hint": "Understand the exact limits for planning and approvals.", "for_endcustomers": "For End Customers", - "for_resellers": "For Resellers", + "for_resellers": "For Partner / Agencies", "view_details": "View details", "details_show": "Show Details", "comparison_title": "Compare Packages", @@ -190,10 +194,10 @@ "not_available": "Not available", "standard_support": "Standard support", "max_tenants": "Max. Tenants", - "max_events": "Max. Events/Year", + "max_events": "Events included", "faq_free": "What is the Free Package?", "faq_upgrade": "Can I upgrade?", - "faq_reseller": "What for Resellers?", + "faq_reseller": "What for Partner / Agencies?", "faq_payment": "Payment secure?", "testimonials": { "anna": "Fotospiel made our wedding perfect! Guests could easily share photos, and the gallery was a hit.", @@ -338,7 +342,7 @@ "purchase_complete_desc": "Log in to continue.", "login": "Log In", "no_account": "No Account? Register", - "manage_subscription": "Manage Subscription", + "manage_subscription": "Manage kontingent", "stripe_dashboard": "Stripe Dashboard", "trial_activated": "Trial activated for 14 days!" }, @@ -481,7 +485,7 @@ "summary_title": "Your order", "package_label": "Selected package", "billing_type_one_time": "One-time purchase (per event)", - "billing_type_subscription": "Subscription (recurring)", + "billing_type_subscription": "One-time purchase (kontingent)", "legal_links_intro": "Details on the withdrawal policy:", "link_terms": "Terms & Conditions", "link_privacy": "Privacy Policy", @@ -490,7 +494,7 @@ "checkbox_terms_error": "Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.", "checkbox_digital_content_label": "I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.", "checkbox_digital_content_error": "Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.", - "hint_subscription_withdrawal": "For subscriptions, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.", + "hint_subscription_withdrawal": "For one-time purchases, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.", "open_withdrawal": "View withdrawal policy", "modal_description": "Below is the current withdrawal policy for your purchase.", "modal_loading": "Loading withdrawal policy…", diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 7e20d44..75a10ab 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -433,6 +433,8 @@ export type TenantPackageSummary = { id: number; package_id: number; package_name: string; + package_type: string | null; + included_package_slug: string | null; active: boolean; used_events: number; remaining_events: number | null; @@ -743,6 +745,7 @@ type EventSavePayload = { status?: 'draft' | 'published' | 'archived'; is_active?: boolean; package_id?: number; + service_package_slug?: string; accepted_waiver?: boolean; settings?: Record & { live_show?: LiveShowSettings; @@ -1008,6 +1011,18 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary { id: Number(pkg.id ?? 0), package_id: Number(pkg.package_id ?? packageData.id ?? 0), package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'), + package_type: + typeof (packageData as any).type === 'string' + ? String((packageData as any).type) + : typeof (pkg as any).package_type === 'string' + ? String((pkg as any).package_type) + : null, + included_package_slug: + typeof (packageData as any).included_package_slug === 'string' + ? String((packageData as any).included_package_slug) + : typeof (pkg as any).included_package_slug === 'string' + ? String((pkg as any).included_package_slug) + : null, active: Boolean(pkg.active ?? false), used_events: Number(pkg.used_events ?? 0), remaining_events: pkg.remaining_events !== undefined ? Number(pkg.remaining_events) : null, @@ -2099,11 +2114,19 @@ export async function submitTenantFeedback(payload: { export type Package = { id: number; name: string; + slug?: string; + type?: 'endcustomer' | 'reseller'; price: number; max_photos: number | null; max_guests: number | null; gallery_days: number | null; - features: Record; + max_events_per_year?: number | null; + included_package_slug?: string | null; + paddle_price_id?: string | null; + paddle_product_id?: string | null; + branding_allowed?: boolean | null; + watermark_allowed?: boolean | null; + features: string[] | Record | null; }; export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise { diff --git a/resources/js/admin/dev-tools.ts b/resources/js/admin/dev-tools.ts index 46f70f4..86ca793 100644 --- a/resources/js/admin/dev-tools.ts +++ b/resources/js/admin/dev-tools.ts @@ -2,8 +2,8 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true const CREDENTIALS: Record = { 'cust-standard-empty': { login: 'standard-empty@demo.fotospiel', password: 'Demo1234!' }, 'cust-starter-wedding': { login: 'starter-wedding@demo.fotospiel', password: 'Demo1234!' }, - 'reseller-s-active': { login: 'reseller-active@demo.fotospiel', password: 'Demo1234!' }, - 'reseller-s-full': { login: 'reseller-full@demo.fotospiel', password: 'Demo1234!' }, + 'reseller-s-active': { login: 'partner-active@demo.fotospiel', password: 'Demo1234!' }, + 'reseller-s-full': { login: 'partner-full@demo.fotospiel', password: 'Demo1234!' }, }; async function loginAs(key: string): Promise { diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index d11aa4f..1c1a400 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -74,8 +74,8 @@ }, "errors": { "generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", - "eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.", - "eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben.", + "eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.", + "eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben im Kontingent.", "photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.", "goToBilling": "Zur Paketverwaltung" }, @@ -174,7 +174,7 @@ "plans": { "title": "Pakete im Überblick", "subtitle": "Wähle das passende Kontingent", - "hint": "Starter, Standard oder Reseller – alles mit Moderation & QR-Codes.", + "hint": "Starter, Standard oder Partner – alles mit Moderation & QR-Codes.", "starter": { "title": "Starter", "badge": "Für ein Event", @@ -191,23 +191,23 @@ "p3": "Support bei Live-Events" }, "reseller": { - "title": "Reseller S", - "badge": "Für Dienstleister", + "title": "Partner Start", + "badge": "Für Agenturen", "highlight": "Mehrere Events parallel verwalten", - "p1": "Bis zu 5 Events pro Paket", + "p1": "Bis zu 5 Events pro Kontingent", "p2": "Aufgaben-Sammlungen und Vorlagen", "p3": "Teamrollen & Rechteverwaltung" } }, "audience": { "title": "Für wen?", - "subtitle": "Endkunden & Reseller im Blick", + "subtitle": "Endkunden & Partner im Blick", "endcustomers": { "title": "Endkund:innen", "description": "Schnell einrichten, mobil moderieren und nach dem Event die Galerie teilen." }, "resellers": { - "title": "Reseller & Agenturen", + "title": "Partner / Agenturen", "description": "Mehrere Events im Blick behalten, Kontingente überwachen und Vorlagen nutzen." }, "cta": "Wenige Klicks bis zum Start" diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json index 7c45e44..96bcbb5 100644 --- a/resources/js/admin/i18n/locales/de/dashboard.json +++ b/resources/js/admin/i18n/locales/de/dashboard.json @@ -32,8 +32,8 @@ "publishedHint": "{{count}} veröffentlicht", "newPhotos": "Neue Fotos (7 Tage)", "taskProgress": "Task-Fortschritt", - "credits": "Event-Slots", - "lowCredits": "Mehr Slots buchen empfohlen" + "credits": "Event-Kontingent", + "lowCredits": "Mehr Kontingent buchen empfohlen" } }, "liveNow": { @@ -238,8 +238,8 @@ "publishedHint": "{{count}} veröffentlicht", "newPhotos": "Neue Fotos (7 Tage)", "taskProgress": "Task-Fortschritt", - "credits": "Event-Slots", - "lowCredits": "Mehr Slots buchen empfohlen" + "credits": "Event-Kontingent", + "lowCredits": "Mehr Kontingent buchen empfohlen" } }, "quickActions": { diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 1157b06..dc77e10 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -90,7 +90,7 @@ }, "warnings": { "noEvents": "Event-Kontingent aufgebraucht. Bitte Paket upgraden oder erneuern.", - "lowEvents": "Nur noch {{remaining}} Event-Slots verfügbar.", + "lowEvents": "Nur noch {{remaining}} Events im Kontingent verfügbar.", "expiresSoon": "Paket läuft am {{date}} ab.", "expired": "Paket ist abgelaufen." } @@ -108,7 +108,7 @@ "expires": "Läuft ab", "warnings": { "noEvents": "Event-Kontingent aufgebraucht.", - "lowEvents": "Nur noch {{remaining}} Events verbleiben.", + "lowEvents": "Nur noch {{remaining}} Events im Kontingent verbleiben.", "expiresSoon": "Läuft am {{date}} ab.", "expired": "Paket ist abgelaufen." } @@ -1558,12 +1558,12 @@ "title": "Benachrichtigungsübersicht", "channel": "E-Mail Kanal", "channelCopy": "Alle Warnungen werden per E-Mail versendet.", - "credits": "Credits", - "threshold": "Warnung bei {{count}} verbleibenden Slots" + "credits": "Event-Kontingent", + "threshold": "Warnung bei {{count}} verbleibenden Events" }, "meta": { - "creditLast": "Letzte Slot-Warnung: {{date}}", - "creditNever": "Noch keine Slot-Warnung versendet." + "creditLast": "Letzte Kontingent-Warnung: {{date}}", + "creditNever": "Noch keine Kontingent-Warnung versendet." }, "items": { "photoThresholds": { @@ -1592,7 +1592,7 @@ }, "eventThresholds": { "label": "Warnung bei Event-Kontingent", - "description": "Hinweis, wenn das Reseller-Paket fast ausgeschöpft ist." + "description": "Hinweis, wenn das Partner / Agentur-Paket fast ausgeschöpft ist." }, "eventLimits": { "label": "Sperre bei Event-Kontingent", @@ -2192,7 +2192,7 @@ "featuresTitle": "Enthaltene Features", "feature": { "priority_support": "Priority Support", - "reseller_dashboard": "Reseller-Dashboard", + "reseller_dashboard": "Partner-Dashboard", "custom_domain": "Eigene Domain", "custom_branding": "Benutzerdefiniertes Branding", "custom_tasks": "Individuelle Aufgaben", @@ -2907,7 +2907,7 @@ "max_guests": "Gäste", "max_tasks": "Aufgaben", "gallery_days": "Galerietage", - "max_events_per_year": "Events pro Jahr" + "max_events_per_year": "Event-Kontingent" }, "mobileEvents": { "edit": "Event bearbeiten" @@ -3064,6 +3064,30 @@ "shop": { "title": "Paket upgraden", "subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.", + "partner": { + "title": "Event-Kontingent kaufen", + "subtitle": "Kaufe Event-Kontingente, um mehrere Events mit unseren Services umzusetzen.", + "buy": "Kaufen", + "unavailable": "Nicht verfügbar", + "confirmSubtitle": "Du kaufst:", + "includedTier": "Inklusive Event-Level: {{tier}}", + "eventsIncluded": "{{count}} Events im Kontingent", + "recommendedUsage": "Empfohlen innerhalb von 24 Monaten zu nutzen.", + "tiers": { + "starter": "Starter", + "standard": "Standard", + "premium": "Premium" + }, + "compare": { + "rows": { + "includedTier": "Inklusive Event-Level", + "events": "Events im Kontingent" + }, + "values": { + "unknown": "—" + } + } + }, "recommendationTitle": "Empfohlen für dich", "recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.", "compare": { diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json index a2cee34..8b1bf54 100644 --- a/resources/js/admin/i18n/locales/de/onboarding.json +++ b/resources/js/admin/i18n/locales/de/onboarding.json @@ -41,7 +41,7 @@ "ctaList": { "choosePackage": { "label": "Dein Eventpaket auswählen", - "description": "Reserviere Event-Slots oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.", + "description": "Reserviere Event-Kontingente oder Pakete, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.", "button": "Weiter zu Paketen" }, "createEvent": { @@ -61,7 +61,7 @@ "steps": { "package": { "title": "Paket sichern", - "hint": "Event-Slots oder ein Abo brauchst du, bevor Gäste live gehen." + "hint": "Event-Kontingent oder Paket brauchst du, bevor Gäste live gehen." }, "invite": { "title": "Team einladen", @@ -77,10 +77,10 @@ "layout": { "eyebrow": "Schritt 2", "title": "Wähle dein Eventpaket", - "subtitle": "Fotospiel bietet flexible Preismodelle: einmalige Event-Slots oder Abos, die mehrere Events abdecken." + "subtitle": "Fotospiel bietet flexible Preismodelle: einzelne Event-Pakete oder Kontingente für mehrere Events." }, "step": { - "title": "Aktiviere die passenden Event-Slots", + "title": "Aktiviere das passende Event-Kontingent", "description": "Sichere dir Kapazität für dein nächstes Event. Du kannst jederzeit upgraden – bezahle nur, was du brauchst." }, "state": { @@ -92,7 +92,7 @@ }, "card": { "subscription": "Abo", - "creditPack": "Event-Slot-Paket", + "creditPack": "Event-Kontingent", "description": "Sofort einsatzbereit für dein nächstes Event.", "descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive – perfekt für lebendige Reportagen.", "active": "Aktives Paket", @@ -151,7 +151,7 @@ }, "details": { "subscription": "Abo", - "creditPack": "Event-Slot-Paket", + "creditPack": "Event-Kontingent", "photos": "Bis zu {{count}} Fotos", "galleryDays": "Galerie {{count}} Tage", "guests": "{{count}} Gäste", @@ -188,7 +188,7 @@ "activate": "Gratis-Paket aktivieren", "progress": "Aktivierung läuft …", "successTitle": "Gratis-Paket aktiviert", - "successDescription": "Deine Event-Slots wurden hinzugefügt. Weiter geht's mit dem Event-Setup.", + "successDescription": "Dein Event-Kontingent wurde hinzugefügt. Weiter geht's mit dem Event-Setup.", "failureTitle": "Aktivierung fehlgeschlagen", "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." }, @@ -205,12 +205,12 @@ "nextSteps": [ "Optional: Abrechnung über Paddle im Billing-Bereich abschließen.", "Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.", - "Vor dem Go-Live Event-Slots prüfen und Gäste-Link teilen." + "Vor dem Go-Live Event-Kontingent prüfen und Gäste-Link teilen." ], "cta": { "billing": { "label": "Abrechnung starten", - "description": "Öffnet den Billing-Bereich mit Paddle- und Slot-Optionen.", + "description": "Öffnet den Billing-Bereich mit Paddle- und Kontingent-Optionen.", "button": "Zu Billing & Zahlung" }, "setup": { diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index bfcd8fa..ea883a5 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -74,8 +74,8 @@ }, "errors": { "generic": "Something went wrong. Please try again.", - "eventLimit": "Your current package has no remaining event slots.", - "eventLimitDetails": "{used} of {limit} events used. {remaining} remaining.", + "eventLimit": "Your current package has no remaining event kontingent.", + "eventLimitDetails": "{used} of {limit} events used. {remaining} remaining in the kontingent.", "photoLimit": "This event reached its photo upload limit.", "goToBilling": "Manage subscription" }, @@ -174,7 +174,7 @@ "plans": { "title": "Packages at a glance", "subtitle": "Choose the right quota", - "hint": "Starter, Standard or Reseller – all include moderation & invites.", + "hint": "Starter, Standard or Partner – all include moderation & invites.", "starter": { "title": "Starter", "badge": "For one event", @@ -191,24 +191,24 @@ "p3": "Support on live days" }, "reseller": { - "title": "Reseller S", - "badge": "For pros", + "title": "Partner Start", + "badge": "For agencies", "highlight": "Manage multiple events", - "p1": "Up to 5 events per package", + "p1": "Up to 5 events per kontingent", "p2": "Task collections and templates", "p3": "Team roles & permissions" } }, "audience": { "title": "Who is it for?", - "subtitle": "Built for hosts and resellers", + "subtitle": "Built for hosts and partners", "endcustomers": { "title": "Event hosts", "description": "Set up fast, moderate on mobile and share the gallery afterwards." }, "resellers": { - "title": "Resellers & agencies", - "description": "Track multiple events, monitor quotas and reuse templates." + "title": "Partner / Agencies", + "description": "Track multiple events, monitor kontingent and reuse templates." }, "cta": "Just a few clicks to go live" }, diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json index 38ebed8..bd4d61c 100644 --- a/resources/js/admin/i18n/locales/en/dashboard.json +++ b/resources/js/admin/i18n/locales/en/dashboard.json @@ -32,8 +32,8 @@ "publishedHint": "{{count}} published", "newPhotos": "New photos (7 days)", "taskProgress": "Task progress", - "credits": "Event slots", - "lowCredits": "Add slots soon" + "credits": "Event kontingent", + "lowCredits": "Add kontingent soon" } }, "liveNow": { @@ -238,8 +238,8 @@ "publishedHint": "{{count}} published", "newPhotos": "New photos (7 days)", "taskProgress": "Task progress", - "credits": "Event slots", - "lowCredits": "Add slots soon" + "credits": "Event kontingent", + "lowCredits": "Add kontingent soon" } }, "quickActions": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index d7d43b4..592cda5 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -90,7 +90,7 @@ }, "warnings": { "noEvents": "Event allowance exhausted. Please upgrade or renew your package.", - "lowEvents": "Only {{remaining}} event slots remaining.", + "lowEvents": "Only {{remaining}} events remaining in the kontingent.", "expiresSoon": "Package expires on {{date}}.", "expired": "Package has expired." } @@ -108,7 +108,7 @@ "expires": "Expires", "warnings": { "noEvents": "Event allowance exhausted.", - "lowEvents": "Only {{remaining}} events left.", + "lowEvents": "Only {{remaining}} events remaining in the kontingent.", "expiresSoon": "Expires on {{date}}.", "expired": "Package has expired." } @@ -1556,12 +1556,12 @@ "title": "Notification overview", "channel": "Email channel", "channelCopy": "All warnings are delivered via email.", - "credits": "Credits", - "threshold": "Warning at {{count}} remaining slots" + "credits": "Event kontingent", + "threshold": "Warning at {{count}} remaining events" }, "meta": { - "creditLast": "Last slot warning: {{date}}", - "creditNever": "No slot warning sent yet." + "creditLast": "Last kontingent warning: {{date}}", + "creditNever": "No kontingent warning sent yet." }, "items": { "photoThresholds": { @@ -1590,7 +1590,7 @@ }, "eventThresholds": { "label": "Event quota warning", - "description": "Notify me when the reseller package is almost used up." + "description": "Notify me when the partner / agency package is almost used up." }, "eventLimits": { "label": "Event quota exhausted", @@ -2196,7 +2196,7 @@ "featuresTitle": "Included features", "feature": { "priority_support": "Priority support", - "reseller_dashboard": "Reseller dashboard", + "reseller_dashboard": "Partner dashboard", "custom_domain": "Custom domain", "custom_branding": "Custom branding", "custom_tasks": "Custom tasks", @@ -2911,7 +2911,7 @@ "max_guests": "Guests", "max_tasks": "Tasks", "gallery_days": "Gallery days", - "max_events_per_year": "Events per year" + "max_events_per_year": "Event kontingent" }, "mobileEvents": { "edit": "Edit event" @@ -3068,6 +3068,30 @@ "shop": { "title": "Upgrade Package", "subtitle": "Choose a package to unlock more features and limits.", + "partner": { + "title": "Buy event kontingent", + "subtitle": "Buy event kontingents to run multiple events with our services.", + "buy": "Buy", + "unavailable": "Unavailable", + "confirmSubtitle": "You're buying:", + "includedTier": "Included event tier: {{tier}}", + "eventsIncluded": "{{count}} events in kontingent", + "recommendedUsage": "Recommended to use within 24 months.", + "tiers": { + "starter": "Starter", + "standard": "Standard", + "premium": "Premium" + }, + "compare": { + "rows": { + "includedTier": "Included event tier", + "events": "Events in kontingent" + }, + "values": { + "unknown": "—" + } + } + }, "recommendationTitle": "Recommended for you", "recommendationBody": "The highlighted package includes the feature you requested.", "compare": { diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json index 68cbeac..e0531a1 100644 --- a/resources/js/admin/i18n/locales/en/onboarding.json +++ b/resources/js/admin/i18n/locales/en/onboarding.json @@ -41,7 +41,7 @@ "ctaList": { "choosePackage": { "label": "Choose your package", - "description": "Reserve event slots or subscriptions to activate events instantly. Flexible options for any event size.", + "description": "Reserve event kontingent or packages to activate events instantly. Flexible options for any event size.", "button": "Continue to packages" }, "createEvent": { @@ -61,7 +61,7 @@ "steps": { "package": { "title": "Secure your package", - "hint": "Event slots or a subscription are required before guests go live." + "hint": "Event kontingent or a package is required before guests go live." }, "invite": { "title": "Invite your co-hosts", @@ -77,10 +77,10 @@ "layout": { "eyebrow": "Step 2", "title": "Choose your package", - "subtitle": "Fotospiel supports flexible pricing: single-use event slots or subscriptions covering multiple events." + "subtitle": "Fotospiel supports flexible pricing: single event packages or kontingent for multiple events." }, "step": { - "title": "Activate the right plan", + "title": "Activate the right event kontingent", "description": "Secure capacity for your next event. Upgrade at any time – only pay for what you need." }, "state": { @@ -92,7 +92,7 @@ }, "card": { "subscription": "Subscription", - "creditPack": "Event slot pack", + "creditPack": "Event kontingent", "description": "Ready for your next event right away.", "descriptionWithPhotos": "Up to {{count}} photos included – perfect for vibrant storytelling.", "active": "Active package", @@ -151,7 +151,7 @@ }, "details": { "subscription": "Subscription", - "creditPack": "Event slot pack", + "creditPack": "Event kontingent", "photos": "Up to {{count}} photos", "galleryDays": "{{count}} gallery days", "guests": "{{count}} guests", @@ -188,7 +188,7 @@ "activate": "Activate free package", "progress": "Activating …", "successTitle": "Free package activated", - "successDescription": "Event slots added. Continue with the setup.", + "successDescription": "Event kontingent added. Continue with the setup.", "failureTitle": "Activation failed", "errorMessage": "The free package could not be activated." }, @@ -205,12 +205,12 @@ "nextSteps": [ "Optional: finish billing via Paddle inside the billing area.", "Complete the event setup and configure tasks, team, and gallery.", - "Check your event slots before go-live and share your guest link." + "Check your event kontingent before go-live and share your guest link." ], "cta": { "billing": { "label": "Start billing", - "description": "Opens the billing area with Paddle plan options.", + "description": "Opens the billing area with Paddle kontingent options.", "button": "Go to billing" }, "setup": { diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index ef88c24..98a0290 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -58,6 +58,12 @@ export default function MobileBillingPage() { const invoicesRef = React.useRef(null); const supportEmail = 'support@fotospiel.de'; const back = useBackNavigation(adminPath('/mobile/profile')); + const shopLink = React.useMemo(() => { + const isPartner = + activePackage?.package_type === 'reseller' || packages.some((pkg) => pkg.package_type === 'reseller'); + + return isPartner ? adminPath('/mobile/billing/shop?type=reseller') : adminPath('/mobile/billing/shop'); + }, [activePackage?.package_type, packages]); const load = React.useCallback(async () => { setLoading(true); @@ -281,7 +287,7 @@ export default function MobileBillingPage() { navigate(adminPath('/mobile/billing/shop'))} + onPress={() => navigate(shopLink)} fullWidth={false} /> @@ -385,7 +391,7 @@ export default function MobileBillingPage() { pkg={activePackage} label={t('billing.sections.packages.card.statusActive', 'Aktiv')} isActive - onOpenShop={() => navigate(adminPath('/mobile/billing/shop'))} + onOpenShop={() => navigate(shopLink)} /> ) : null} {packages @@ -501,6 +507,15 @@ function PackageCard({ const { t } = useTranslation('management'); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const limits = (pkg.package_limits ?? null) as Record | null; + const isPartnerPackage = pkg.package_type === 'reseller'; + const includedTierLabel = + pkg.included_package_slug === 'starter' + ? t('shop.partner.tiers.starter', 'Starter') + : pkg.included_package_slug === 'standard' + ? t('shop.partner.tiers.standard', 'Standard') + : pkg.included_package_slug === 'pro' + ? t('shop.partner.tiers.premium', 'Premium') + : pkg.included_package_slug; const limitMaxEvents = typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null; const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0; const remainingText = @@ -520,7 +535,7 @@ function PackageCard({ const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents: pkg.remaining_events ?? null, usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null, - }); + }, { packageType: pkg.package_type }); const featureKeys = collectPackageFeatures(pkg); const eventUsageText = formatEventUsage( typeof pkg.used_events === 'number' ? pkg.used_events : null, @@ -550,8 +565,9 @@ function PackageCard({ {pkg.price !== null && pkg.price !== undefined ? ( {formatAmount(pkg.price, pkg.currency ?? 'EUR')} ) : null} - {renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))} - {renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))} + {isPartnerPackage && includedTierLabel ? {includedTierLabel} : null} + {!isPartnerPackage ? renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding')) : null} + {!isPartnerPackage ? renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark')) : null} {eventUsageText ? ( diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 7771e9f..9869df9 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -354,6 +354,7 @@ export default function MobileDashboardPage() { navigate(adminPath('/mobile/events/new')); }} packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')} + packageType={activePackage.package_type ?? null} remainingEvents={remainingEvents} purchasedAt={activePackage.purchased_at} expiresAt={activePackage.expires_at} @@ -497,6 +498,7 @@ function PackageSummarySheet({ onClose, onContinue, packageName, + packageType, remainingEvents, purchasedAt, expiresAt, @@ -508,6 +510,7 @@ function PackageSummarySheet({ onClose: () => void; onContinue: () => void; packageName: string; + packageType: string | null; remainingEvents: number | null | undefined; purchasedAt: string | null | undefined; expiresAt: string | null | undefined; @@ -523,8 +526,9 @@ function PackageSummarySheet({ package_limits: limits, branding_allowed: (limits as any)?.branding_allowed ?? null, watermark_allowed: (limits as any)?.watermark_allowed ?? null, + package_type: packageType, } as any); - const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents }); + const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents }, { packageType }); const hasFeatures = resolvedFeatures.length > 0; const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown'); diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index 2935166..bc94040 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -9,7 +9,19 @@ import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton } from './components/Primitives'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { LegalConsentSheet } from './components/LegalConsentSheet'; -import { createEvent, getEvent, updateEvent, getEventTypes, getPackages, Package, TenantEvent, TenantEventType, trackOnboarding } from '../api'; +import { + createEvent, + getEvent, + updateEvent, + getEventTypes, + getPackages, + getTenantPackagesOverview, + Package, + TenantEvent, + TenantEventType, + TenantPackageSummary, + trackOnboarding, +} from '../api'; import { resolveEventSlugAfterUpdate } from './eventFormNavigation'; import { adminPath } from '../constants'; import { isAuthError } from '../auth/tokens'; @@ -30,6 +42,7 @@ type FormState = { autoApproveUploads: boolean; tasksEnabled: boolean; packageId: number | null; + servicePackageSlug: string | null; }; export default function MobileEventFormPage() { @@ -52,11 +65,14 @@ export default function MobileEventFormPage() { autoApproveUploads: true, tasksEnabled: true, packageId: null, + servicePackageSlug: null, }); const [eventTypes, setEventTypes] = React.useState([]); const [typesLoading, setTypesLoading] = React.useState(false); const [packages, setPackages] = React.useState([]); const [packagesLoading, setPackagesLoading] = React.useState(false); + const [kontingentOptions, setKontingentOptions] = React.useState>([]); + const [kontingentLoading, setKontingentLoading] = React.useState(false); const [loading, setLoading] = React.useState(isEdit); const [saving, setSaving] = React.useState(false); const [consentOpen, setConsentOpen] = React.useState(false); @@ -84,6 +100,7 @@ export default function MobileEventFormPage() { (data.settings?.engagement_mode as string | undefined) !== 'photo_only' && (data.engagement_mode as string | undefined) !== 'photo_only', packageId: null, + servicePackageSlug: null, }); setError(null); } catch (err) { @@ -139,6 +156,75 @@ export default function MobileEventFormPage() { })(); }, [isSuperAdmin, isEdit]); + React.useEffect(() => { + if (isEdit) { + return; + } + + (async () => { + setKontingentLoading(true); + try { + const overview = await getTenantPackagesOverview(); + const packages = overview.packages ?? []; + + const active = packages.filter((pkg) => pkg.active && pkg.package_type === 'reseller'); + const totals = new Map(); + + active.forEach((pkg: TenantPackageSummary) => { + const slugValue = pkg.included_package_slug ?? 'standard'; + if (!slugValue) { + return; + } + + const remaining = Number.isFinite(pkg.remaining_events as number) ? Number(pkg.remaining_events) : 0; + if (remaining <= 0) { + return; + } + + totals.set(slugValue, (totals.get(slugValue) ?? 0) + remaining); + }); + + const options = Array.from(totals.entries()) + .map(([slugValue, remaining]) => ({ slug: slugValue, remaining })) + .sort((a, b) => a.slug.localeCompare(b.slug)); + + setKontingentOptions(options); + setForm((prev) => { + if (prev.servicePackageSlug || options.length === 0) { + return prev; + } + + if (options.length === 1) { + return { ...prev, servicePackageSlug: options[0].slug }; + } + + const standard = options.find((row) => row.slug === 'standard'); + return { ...prev, servicePackageSlug: standard?.slug ?? options[0].slug }; + }); + } catch { + setKontingentOptions([]); + } finally { + setKontingentLoading(false); + } + })(); + }, [isEdit]); + + const resolveServiceTierLabel = React.useCallback((slugValue: string) => { + if (slugValue === 'starter') { + return 'Starter'; + } + + if (slugValue === 'standard') { + return 'Standard'; + } + + if (slugValue === 'pro') { + return 'Premium'; + } + + return slugValue; + }, []); + async function handleSubmit() { setSaving(true); setError(null); @@ -165,6 +251,7 @@ export default function MobileEventFormPage() { event_date: form.date || undefined, status: form.published ? 'published' : 'draft', package_id: isSuperAdmin ? form.packageId ?? undefined : undefined, + service_package_slug: form.servicePackageSlug ?? undefined, settings: { location: form.location, guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', @@ -188,6 +275,7 @@ export default function MobileEventFormPage() { event_date: form.date || undefined, status: form.published ? 'published' : 'draft', package_id: isSuperAdmin ? form.packageId ?? undefined : undefined, + service_package_slug: form.servicePackageSlug ?? undefined, settings: { location: form.location, guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', @@ -283,6 +371,34 @@ export default function MobileEventFormPage() { ) : null} + {!isEdit && (kontingentLoading || kontingentOptions.length > 0) ? ( + + {kontingentLoading ? ( + + {t('eventForm.fields.servicePackage.loading', 'Loading Event-Kontingente…')} + + ) : ( + setForm((prev) => ({ ...prev, servicePackageSlug: String(e.target.value) }))} + > + + {kontingentOptions.map((opt) => ( + + ))} + + )} + + {t( + 'eventForm.fields.servicePackage.help', + 'Wählt das Event-Level. Pro Event wird 1 aus dem passenden Event-Kontingent verbraucht.', + )} + + + ) : null} + getPackages('endcustomer'), - }); + const forcedCatalogType = searchParams.get('type'); const { data: inventory, isLoading: loadingInventory } = useQuery({ queryKey: ['tenant-packages-overview'], queryFn: () => getTenantPackagesOverview({ force: true }), }); + const catalogType: 'endcustomer' | 'reseller' = + forcedCatalogType === 'endcustomer' || forcedCatalogType === 'reseller' + ? forcedCatalogType + : inventory?.activePackage?.package_type === 'reseller' || + (inventory?.packages ?? []).some((entry) => entry.package_type === 'reseller') + ? 'reseller' + : 'endcustomer'; + + const { data: catalog, isLoading: loadingCatalog } = useQuery({ + queryKey: ['packages', catalogType], + queryFn: () => getPackages(catalogType), + }); + const isLoading = loadingCatalog || loadingInventory; if (isLoading) { return ( - navigate(-1)} activeTab="profile"> + navigate(-1)} + activeTab="profile" + > @@ -65,7 +82,10 @@ export default function MobilePackageShopPage() { const activePackageId = inventory?.activePackage?.package_id ?? null; const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null; - const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage); + const recommendedPackageId = + catalogType === 'reseller' + ? null + : selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage); // Merge and sort packages const sortedPackages = [...(catalog || [])].sort((a, b) => { @@ -78,10 +98,14 @@ export default function MobilePackageShopPage() { }); const packageEntries = sortedPackages.map((pkg) => { - const owned = inventory?.packages?.find((entry) => entry.package_id === pkg.id); - const isActive = inventory?.activePackage?.package_id === pkg.id; + const ownedEntries = (inventory?.packages ?? []).filter((entry) => entry.package_id === pkg.id && entry.active); + const owned = ownedEntries.length ? aggregateOwnedEntries(ownedEntries) : undefined; + const isActive = catalogType === 'reseller' ? false : inventory?.activePackage?.package_id === pkg.id; const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false; - const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage); + const { isUpgrade, isDowngrade } = + catalogType === 'reseller' + ? { isUpgrade: false, isDowngrade: false } + : classifyPackageChange(pkg, activeCatalogPackage); return { pkg, @@ -94,9 +118,13 @@ export default function MobilePackageShopPage() { }); return ( - navigate(-1)} activeTab="profile"> + navigate(-1)} + activeTab="profile" + > - {recommendedFeature && ( + {catalogType !== 'reseller' && recommendedFeature && ( @@ -112,7 +140,9 @@ export default function MobilePackageShopPage() { - {t('shop.subtitle', 'Choose a package to unlock more features and limits.')} + {catalogType === 'reseller' + ? t('shop.partner.subtitle', 'Kaufe Event-Kontingente, um mehrere Events mit unseren Services umzusetzen.') + : t('shop.subtitle', 'Choose a package to unlock more features and limits.')} @@ -140,6 +170,7 @@ export default function MobilePackageShopPage() { setSelectedPackage(pkg)} + catalogType={catalogType} /> ) : ( packageEntries.map((entry) => ( @@ -151,6 +182,7 @@ export default function MobilePackageShopPage() { isRecommended={entry.isRecommended} isUpgrade={entry.isUpgrade} isDowngrade={entry.isDowngrade} + catalogType={catalogType} onSelect={() => setSelectedPackage(entry.pkg)} /> )) @@ -168,6 +200,7 @@ function PackageShopCard({ isRecommended, isUpgrade, isDowngrade, + catalogType, onSelect }: { pkg: Package; @@ -176,14 +209,17 @@ function PackageShopCard({ isRecommended?: any; isUpgrade?: boolean; isDowngrade?: boolean; + catalogType: 'endcustomer' | 'reseller'; onSelect: () => void }) { const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { t } = useTranslation('management'); + const isResellerCatalog = catalogType === 'reseller'; const statusLabel = getPackageStatusLabel({ t, isActive, owned }); - const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive); - const canSelect = canSelectPackage(isUpgrade, isActive); + const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive); + const canSelect = isResellerCatalog ? Boolean(pkg.paddle_price_id) : canSelectPackage(isUpgrade, isActive); + const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null); return ( {isRecommended && {t('shop.badges.recommended', 'Recommended')}} - {isUpgrade && !isActive ? {t('shop.badges.upgrade', 'Upgrade')} : null} - {isDowngrade && !isActive ? {t('shop.badges.downgrade', 'Downgrade')} : null} - {isActive && {t('shop.badges.active', 'Active')}} + {!isResellerCatalog && isUpgrade && !isActive ? ( + {t('shop.badges.upgrade', 'Upgrade')} + ) : null} + {!isResellerCatalog && isDowngrade && !isActive ? ( + {t('shop.badges.downgrade', 'Downgrade')} + ) : null} + {!isResellerCatalog && isActive ? {t('shop.badges.active', 'Active')} : null} @@ -224,34 +264,58 @@ function PackageShopCard({ - {pkg.max_photos ? ( - + {isResellerCatalog ? ( + <> + {includedTierLabel ? ( + + ) : null} + {typeof pkg.max_events_per_year === 'number' ? ( + + ) : null} + + ) : ( - + <> + {pkg.max_photos ? ( + + ) : ( + + )} + {pkg.gallery_days ? ( + + ) : null} + )} - {pkg.gallery_days ? ( - - ) : null} {/* Render specific feature if it was requested */} - {getEnabledPackageFeatures(pkg) - .filter((key) => !pkg.max_photos || key !== 'photos') - .slice(0, 3) - .map((key) => ( - - ))} + {!isResellerCatalog + ? getEnabledPackageFeatures(pkg) + .filter((key) => !pkg.max_photos || key !== 'photos') + .slice(0, 3) + .map((key) => ( + + )) + : null} @@ -280,9 +344,11 @@ type PackageEntry = { function PackageShopCompareView({ entries, onSelect, + catalogType, }: { entries: PackageEntry[]; onSelect: (pkg: Package) => void; + catalogType: 'endcustomer' | 'reseller'; }) { const { t } = useTranslation('management'); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); @@ -308,9 +374,18 @@ function PackageShopCompareView({ if (row.limitKey === 'max_guests') { return t('shop.compare.rows.guests', 'Guests'); } + if (row.limitKey === 'max_events_per_year') { + return t('shop.partner.compare.rows.events', 'Events im Kontingent'); + } return t('shop.compare.rows.days', 'Gallery days'); } + if (row.type === 'value') { + if (row.valueKey === 'included_package_slug') { + return t('shop.partner.compare.rows.includedTier', 'Inklusive Event-Level'); + } + } + return t(`shop.features.${row.featureKey}`, row.featureKey); }; @@ -362,13 +437,15 @@ function PackageShopCompareView({ {entry.isRecommended ? ( {t('shop.badges.recommended', 'Recommended')} ) : null} - {entry.isUpgrade && !entry.isActive ? ( + {catalogType !== 'reseller' && entry.isUpgrade && !entry.isActive ? ( {t('shop.badges.upgrade', 'Upgrade')} ) : null} - {entry.isDowngrade && !entry.isActive ? ( + {catalogType !== 'reseller' && entry.isDowngrade && !entry.isActive ? ( {t('shop.badges.downgrade', 'Downgrade')} ) : null} - {entry.isActive ? {t('shop.badges.active', 'Active')} : null} + {catalogType !== 'reseller' && entry.isActive ? ( + {t('shop.badges.active', 'Active')} + ) : null} {statusLabel ? ( @@ -391,6 +468,13 @@ function PackageShopCompareView({ {formatLimitValue(value)} ); + } else if (row.type === 'value') { + content = ( + + {resolveIncludedTierLabel(t, entry.pkg.included_package_slug ?? null) ?? + t('shop.partner.compare.values.unknown', '—')} + + ); } else if (row.type === 'feature') { const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey); content = ( @@ -425,12 +509,17 @@ function PackageShopCompareView({ {entries.map((entry) => { - const canSelect = canSelectPackage(entry.isUpgrade, entry.isActive); - const label = entry.isActive - ? t('shop.manage', 'Manage Plan') - : entry.isUpgrade - ? t('shop.select', 'Select') - : t('shop.selectDisabled', 'Not available'); + const isResellerCatalog = catalogType === 'reseller'; + const canSelect = isResellerCatalog ? Boolean(entry.pkg.paddle_price_id) : canSelectPackage(entry.isUpgrade, entry.isActive); + const label = isResellerCatalog + ? canSelect + ? t('shop.partner.buy', 'Kaufen') + : t('shop.partner.unavailable', 'Nicht verfügbar') + : entry.isActive + ? t('shop.manage', 'Manage Plan') + : entry.isUpgrade + ? t('shop.select', 'Select') + : t('shop.selectDisabled', 'Not available'); return ( @@ -438,7 +527,15 @@ function PackageShopCompareView({ label={label} onPress={canSelect ? () => onSelect(entry.pkg) : undefined} disabled={!canSelect} - tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'} + tone={ + catalogType === 'reseller' + ? canSelect + ? 'primary' + : 'ghost' + : entry.isActive || entry.isDowngrade + ? 'ghost' + : 'primary' + } /> ); @@ -488,11 +585,16 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => await startCheckout(pkg.id); }; + const subtitle = + pkg.type === 'reseller' + ? t('shop.partner.confirmSubtitle', 'Du kaufst:') + : t('shop.confirmSubtitle', 'You are upgrading to:'); + return ( - {t('shop.confirmSubtitle', 'You are upgrading to:')} + {subtitle} {pkg.name} {new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)} @@ -556,3 +658,43 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => ); } + +function aggregateOwnedEntries(entries: TenantPackageSummary[]): TenantPackageSummary { + const remainingTotal = entries.reduce( + (total, entry) => total + (typeof entry.remaining_events === 'number' ? entry.remaining_events : 0), + 0 + ); + const usedTotal = entries.reduce( + (total, entry) => total + (typeof entry.used_events === 'number' ? entry.used_events : 0), + 0 + ); + + return { + ...entries[0], + used_events: usedTotal, + remaining_events: Number.isFinite(remainingTotal) ? remainingTotal : entries[0].remaining_events, + }; +} + +function resolveIncludedTierLabel( + t: (key: string, fallback?: string, options?: Record) => string, + slug: string | null +): string | null { + if (!slug) { + return null; + } + + if (slug === 'starter') { + return t('shop.partner.tiers.starter', 'Starter'); + } + + if (slug === 'standard') { + return t('shop.partner.tiers.standard', 'Standard'); + } + + if (slug === 'pro') { + return t('shop.partner.tiers.premium', 'Premium'); + } + + return slug; +} diff --git a/resources/js/admin/mobile/__tests__/billingUsage.test.ts b/resources/js/admin/mobile/__tests__/billingUsage.test.ts index 2dbca2e..2e901e4 100644 --- a/resources/js/admin/mobile/__tests__/billingUsage.test.ts +++ b/resources/js/admin/mobile/__tests__/billingUsage.test.ts @@ -6,6 +6,8 @@ const basePackage: TenantPackageSummary = { id: 1, package_id: 1, package_name: 'Pro', + package_type: 'reseller', + included_package_slug: 'pro', active: true, used_events: 2, remaining_events: 3, diff --git a/resources/js/admin/mobile/lib/packageShop.ts b/resources/js/admin/mobile/lib/packageShop.ts index cc956d6..fd9b58a 100644 --- a/resources/js/admin/mobile/lib/packageShop.ts +++ b/resources/js/admin/mobile/lib/packageShop.ts @@ -9,7 +9,12 @@ export type PackageComparisonRow = | { id: string; type: 'limit'; - limitKey: 'max_photos' | 'max_guests' | 'gallery_days'; + limitKey: 'max_photos' | 'max_guests' | 'gallery_days' | 'max_events_per_year'; + } + | { + id: string; + type: 'value'; + valueKey: 'included_package_slug'; } | { id: string; @@ -62,6 +67,10 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac return { isUpgrade: false, isDowngrade: false }; } + if (pkg.type === 'reseller' || active.type === 'reseller') { + return { isUpgrade: false, isDowngrade: false }; + } + const activeFeatures = collectFeatures(active); const candidateFeatures = collectFeatures(pkg); @@ -106,6 +115,10 @@ export function selectRecommendedPackageId( return null; } + if (packages.some((pkg) => pkg.type === 'reseller')) { + return null; + } + const candidates = feature === 'watermark_allowed' ? packages.filter((pkg) => pkg.watermark_allowed === true) : packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature)); @@ -121,11 +134,20 @@ export function selectRecommendedPackageId( } export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] { - const limitRows: PackageComparisonRow[] = [ - { id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' }, - { id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' }, - { id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' }, - ]; + const isResellerCatalog = packages.some( + (pkg) => pkg.type === 'reseller' || pkg.max_events_per_year !== undefined || pkg.included_package_slug !== undefined + ); + + const limitRows: PackageComparisonRow[] = isResellerCatalog + ? [ + { id: 'value.included_package_slug', type: 'value', valueKey: 'included_package_slug' }, + { id: 'limit.max_events_per_year', type: 'limit', limitKey: 'max_events_per_year' }, + ] + : [ + { id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' }, + { id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' }, + { id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' }, + ]; const featureKeys = new Set(); packages.forEach((pkg) => { diff --git a/resources/js/admin/mobile/lib/packageSummary.ts b/resources/js/admin/mobile/lib/packageSummary.ts index 8091fbc..2865c32 100644 --- a/resources/js/admin/mobile/lib/packageSummary.ts +++ b/resources/js/admin/mobile/lib/packageSummary.ts @@ -174,13 +174,19 @@ export function formatPackageLimit(value: number | null | undefined, t: Translat export function getPackageLimitEntries( limits: Record | null, t: Translate, - usageOverrides: LimitUsageOverrides = {} + usageOverrides: LimitUsageOverrides = {}, + options: { packageType?: string | null } = {} ): PackageLimitEntry[] { if (!limits) { return []; } - return LIMIT_LABELS.map(({ key, labelKey, fallback }) => ({ + const labels = + options.packageType === 'reseller' + ? LIMIT_LABELS.filter(({ key }) => key === 'max_events_per_year') + : LIMIT_LABELS; + + return labels.map(({ key, labelKey, fallback }) => ({ key, label: t(labelKey, fallback), value: formatLimitWithRemaining( @@ -231,11 +237,11 @@ export function collectPackageFeatures(pkg: TenantPackageSummary): string[] { } }); - if (pkg.branding_allowed) { + if (pkg.package_type !== 'reseller' && pkg.branding_allowed) { features.add('branding_allowed'); } - if (pkg.watermark_allowed) { + if (pkg.package_type !== 'reseller' && pkg.watermark_allowed) { features.add('watermark_allowed'); } diff --git a/resources/js/i18n.ts b/resources/js/i18n.ts index 89a5492..0179149 100644 --- a/resources/js/i18n.ts +++ b/resources/js/i18n.ts @@ -29,7 +29,7 @@ i18n }, backend: { // Cache-bust to ensure fresh translations when files change. - loadPath: '/lang/{{lng}}/{{ns}}.json?v=20251222', + loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250116', }, react: { useSuspense: true, diff --git a/resources/js/pages/marketing/Packages.tsx b/resources/js/pages/marketing/Packages.tsx index ca52bd8..2186b74 100644 --- a/resources/js/pages/marketing/Packages.tsx +++ b/resources/js/pages/marketing/Packages.tsx @@ -28,6 +28,7 @@ interface Package { price: number; events: number | null; features: string[]; + included_package_slug?: string | null; max_events_per_year?: number | null; limits?: { max_photos?: number; @@ -62,9 +63,10 @@ const sortPackagesByPrice = (packages: Package[]): Package[] => interface PackageComparisonProps { packages: Package[]; variant: 'endcustomer' | 'reseller'; + serviceTierNames?: Record; } -const buildDisplayFeatures = (pkg: Package): string[] => { +const buildDisplayFeatures = (pkg: Package, variant: 'endcustomer' | 'reseller'): string[] => { const features = [...pkg.features]; const removeFeature = (key: string) => { @@ -80,20 +82,22 @@ const buildDisplayFeatures = (pkg: Package): string[] => { } }; - const watermarkFeature = resolveWatermarkFeatureKey(pkg); - ['watermark', 'no_watermark', 'watermark_base', 'watermark_custom'].forEach(removeFeature); - addFeature(watermarkFeature); + if (variant === 'endcustomer') { + const watermarkFeature = resolveWatermarkFeatureKey(pkg); + ['watermark', 'no_watermark', 'watermark_base', 'watermark_custom'].forEach(removeFeature); + addFeature(watermarkFeature); - if (pkg.branding_allowed) { - addFeature('custom_branding'); - } else { - removeFeature('custom_branding'); + if (pkg.branding_allowed) { + addFeature('custom_branding'); + } else { + removeFeature('custom_branding'); + } } return Array.from(new Set(features)); }; -function PackageComparison({ packages, variant }: PackageComparisonProps) { +function PackageComparison({ packages, variant, serviceTierNames = {} }: PackageComparisonProps) { const { t } = useTranslation('marketing'); const { t: tCommon } = useTranslation('common'); @@ -135,12 +139,19 @@ function PackageComparison({ packages, variant }: PackageComparisonProps) { { key: 'price', label: t('packages.price'), - value: (pkg: Package) => `${formatPrice(pkg)} / ${t('packages.billing_per_year')}`, + value: (pkg: Package) => `${formatPrice(pkg)} / ${t('packages.billing_per_kontingent')}`, }, { - key: 'max_tenants', - label: t('packages.max_tenants'), - value: (pkg: Package) => pkg.limits?.max_tenants?.toLocaleString() ?? tCommon('unlimited'), + key: 'included_package_slug', + label: t('packages.included_package_label', 'Inklusive Event-Level'), + value: (pkg: Package) => { + const slug = pkg.included_package_slug ?? null; + if (!slug) { + return tCommon('unlimited'); + } + + return serviceTierNames[slug] ?? slug; + }, }, { key: 'max_events_per_year', @@ -150,24 +161,51 @@ function PackageComparison({ packages, variant }: PackageComparisonProps) { }, ]; - const features = [ - { - key: 'watermark', - label: t('packages.watermark_label'), - value: (pkg: Package) => t(`packages.feature_${resolveWatermarkFeatureKey(pkg)}`), - }, - { - key: 'branding', - label: t('packages.feature_custom_branding'), - value: (pkg: Package) => (pkg.branding_allowed ? t('packages.available') : t('packages.not_available')), - }, - { - key: 'support', - label: t('packages.feature_support'), - value: (pkg: Package) => - pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_support'), - }, - ]; + const features = + variant === 'endcustomer' + ? [ + { + key: 'watermark', + label: t('packages.watermark_label'), + value: (pkg: Package) => t(`packages.feature_${resolveWatermarkFeatureKey(pkg)}`), + }, + { + key: 'branding', + label: t('packages.feature_custom_branding'), + value: (pkg: Package) => (pkg.branding_allowed ? t('packages.available') : t('packages.not_available')), + }, + { + key: 'support', + label: t('packages.feature_support'), + value: (pkg: Package) => + pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_support'), + }, + ] + : [ + { + key: 'recommended_usage_window', + label: t('packages.recommended_usage_label', 'Empfehlung'), + value: () => t('packages.recommended_usage_window'), + }, + { + key: 'support', + label: t('packages.feature_support'), + value: (pkg: Package) => + pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_support'), + }, + { + key: 'dashboard', + label: t('packages.feature_reseller_dashboard'), + value: (pkg: Package) => + pkg.features.includes('reseller_dashboard') ? t('packages.available') : t('packages.not_available'), + }, + { + key: 'reporting', + label: t('packages.feature_advanced_reporting'), + value: (pkg: Package) => + pkg.features.includes('advanced_reporting') ? t('packages.available') : t('packages.not_available'), + }, + ]; return (
@@ -258,6 +296,15 @@ interface PackagesProps { const Packages: React.FC = ({ endcustomerPackages, resellerPackages }) => { const [open, setOpen] = useState(false); const [selectedPackage, setSelectedPackage] = useState(null); + const serviceTierNames = useMemo(() => { + const map: Record = {}; + endcustomerPackages.forEach((pkg) => { + if (pkg?.slug) { + map[pkg.slug] = pkg.name; + } + }); + return map; + }, [endcustomerPackages]); const [isMobile, setIsMobile] = useState(false); const dialogScrollRef = useRef(null); const dialogHeadingRef = useRef(null); @@ -499,6 +546,26 @@ type PackageMetric = { value: string; }; +const resolveServiceTierLabel = (slug: string | null | undefined): string => { + if (!slug) { + return ''; + } + + if (slug === 'starter') { + return 'Starter'; + } + + if (slug === 'standard') { + return 'Standard'; + } + + if (slug === 'pro') { + return 'Premium'; + } + + return slug; +}; + const resolvePackageMetrics = ( pkg: Package, variant: 'endcustomer' | 'reseller', @@ -508,11 +575,9 @@ const resolvePackageMetrics = ( if (variant === 'reseller') { return [ { - key: 'max_tenants', - label: t('packages.max_tenants'), - value: pkg.limits?.max_tenants - ? pkg.limits.max_tenants.toLocaleString() - : tCommon('unlimited'), + key: 'included_package_slug', + label: t('packages.included_package_label', 'Inklusive Event-Level'), + value: resolveServiceTierLabel(pkg.included_package_slug) || tCommon('unlimited'), }, { key: 'max_events_per_year', @@ -522,9 +587,9 @@ const resolvePackageMetrics = ( : tCommon('unlimited'), }, { - key: 'branding', - label: t('packages.feature_custom_branding'), - value: pkg.branding_allowed ? tCommon('included') : t('packages.feature_no_branding'), + key: 'recommended_usage_window', + label: t('packages.recommended_usage_label', 'Empfehlung'), + value: t('packages.recommended_usage_window'), }, ]; } @@ -588,7 +653,7 @@ function PackageCard({ : `${numericPrice.toLocaleString()} ${t('packages.currency.euro')}`; const cadenceLabel = variant === 'reseller' - ? t('packages.billing_per_year') + ? t('packages.billing_per_kontingent') : t('packages.billing_per_event'); const typeLabel = variant === 'reseller' ? t('packages.subscription') : t('packages.one_time'); @@ -601,7 +666,7 @@ function PackageCard({ ? t('packages.badge_starter') : null; - const displayFeatures = buildDisplayFeatures(pkg); + const displayFeatures = buildDisplayFeatures(pkg, variant); const keyFeatures = displayFeatures.slice(0, 3); const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5); const metrics = resolvePackageMetrics(pkg, variant, t, tCommon); @@ -736,8 +801,8 @@ const PackageDetailGrid: React.FC = ({ }) => { const metrics = resolvePackageMetrics(packageData, variant, t, tCommon); const highlightFeatures = useMemo( - () => buildDisplayFeatures(packageData).slice(0, 5), - [packageData], + () => buildDisplayFeatures(packageData, variant).slice(0, 5), + [packageData, variant], ); return ( @@ -756,7 +821,7 @@ const PackageDetailGrid: React.FC = ({

{packageData.price > 0 && (

- / {variant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')} + / {variant === 'reseller' ? t('packages.billing_per_kontingent') : t('packages.billing_per_event')}

)}
@@ -1015,7 +1080,7 @@ const PackageDetailGrid: React.FC = ({ /> ))} - + diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 991478d..ba6e541 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -46,7 +46,7 @@ return [ 'emotion' => 'Emotion', 'event_type' => 'Eventtyp', 'last_activity' => 'Letzte Aktivität', - 'credits' => 'Credits', + 'credits' => 'Event-Kontingent', 'settings' => 'Einstellungen', 'join' => 'Beitreten', 'unnamed' => 'Ohne Namen', @@ -517,7 +517,7 @@ return [ 'heading' => 'Uploads (14 Tage)', ], 'credit_alerts' => [ - 'low_balance_label' => 'Mandanten mit niedrigen Credits', + 'low_balance_label' => 'Mandanten mit niedrigem Event-Kontingent', 'low_balance_desc' => 'Benötigen Betreuung', 'monthly_revenue_label' => 'Umsatz (Monat)', 'monthly_revenue_desc' => 'Aktueller Monat (:month)', @@ -546,10 +546,10 @@ return [ 'name' => 'Mandantenname', 'slug' => 'Slug', 'contact_email' => 'Kontakt‑E‑Mail', - 'event_credits_balance' => 'Event‑Credits‑Kontostand', + 'event_credits_balance' => 'Event-Kontingent', 'features' => 'Funktionen', 'total_revenue' => 'Gesamtumsatz', - 'active_reseller_package' => 'Aktives Reseller-Paket', + 'active_reseller_package' => 'Aktives Partner / Agentur-Paket', 'remaining_events' => 'Verbleibende Events', 'package_expires_at' => 'Ablaufdatum Paket', 'is_active' => 'Aktiv', @@ -574,12 +574,12 @@ return [ 'timeline' => 'Audit Timeline', ], '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' => 'Kontingent anpassen', + 'adjust_credits_delta' => 'Event-Kontingent (positiv/negativ)', + 'adjust_credits_delta_hint' => 'Positive Werte fügen Kontingent 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.', + 'adjust_credits_success_title' => 'Kontingent aktualisiert', + 'adjust_credits_success_body' => 'Das Kontingent wurde um :delta verändert. Neuer Stand: :balance.', 'lifecycle' => 'Lebenszyklus', 'activate' => 'Aktivieren', 'deactivate' => 'Deaktivieren', @@ -663,7 +663,7 @@ return [ 'fields' => [ 'tenant' => 'Mandant', 'package' => 'Paket', - 'credits' => 'Credits', + 'credits' => 'Event-Kontingent', 'price' => 'Preis', 'currency' => 'Währung', 'platform' => 'Plattform', diff --git a/resources/lang/de/api.php b/resources/lang/de/api.php index afa5b8d..db3966f 100644 --- a/resources/lang/de/api.php +++ b/resources/lang/de/api.php @@ -14,4 +14,38 @@ return [ 'default_title' => 'Zugang verweigert', 'default_message' => 'Mit diesem QR-Zugang konnte kein Zugriff gewährt werden.', ], + 'packages' => [ + 'event_tier_unavailable' => [ + 'title' => 'Gewähltes Event-Level nicht verfügbar', + 'message' => 'Für das gewählte Event-Level ist kein Event-Kontingent verfügbar. Bitte wähle ein anderes Level oder kaufe das passende Event-Kontingent.', + ], + 'event_limit_exceeded' => [ + 'title' => 'Event-Kontingent aufgebraucht', + 'message' => 'Dein aktuelles Event-Kontingent enthält keine freien Events mehr. Kaufe ein weiteres Event-Kontingent, um neue Events zu erstellen.', + ], + 'event_limit_missing' => [ + 'title' => 'Kein Paket zugewiesen', + 'message' => 'Kaufe ein Event-Kontingent, um Events zu erstellen.', + ], + 'event_not_found' => [ + 'title' => 'Event nicht verfügbar', + 'message' => 'Das gewählte Event wurde nicht gefunden oder gehört zu einem anderen Tenant.', + ], + 'event_package_missing' => [ + 'title' => 'Event-Paket fehlt', + 'message' => 'Für dieses Event ist kein Paket hinterlegt. Weise ein Paket zu, um Uploads zu ermöglichen.', + ], + 'photo_limit_exceeded' => [ + 'title' => 'Foto-Limit erreicht', + 'message' => 'Dieses Event hat sein Foto-Kontingent erreicht. Upgrade das Event-Paket, um weitere Uploads zu erlauben.', + ], + 'tenant_photo_limit_exceeded' => [ + 'title' => 'Tenant-Foto-Limit erreicht', + 'message' => 'Dieser Tenant hat das Foto-Kontingent für dieses Event erreicht.', + ], + 'tenant_storage_limit_exceeded' => [ + 'title' => 'Tenant-Speicherlimit erreicht', + 'message' => 'Dieser Tenant hat sein Speicher-Kontingent erreicht.', + ], + ], ]; diff --git a/resources/lang/de/marketing.json b/resources/lang/de/marketing.json index ef857ec..6b3706d 100644 --- a/resources/lang/de/marketing.json +++ b/resources/lang/de/marketing.json @@ -47,21 +47,34 @@ "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", "tab_endcustomer": "Endkunden", - "tab_reseller": "Reseller & Agenturen", + "tab_reseller": "Partner / Agentur", "section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)", - "section_reseller": "Packages für Reseller (Jährliches Abo)", + "section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)", "free": "Kostenlos", "one_time": "Einmalkauf", - "subscription": "Abo", + "subscription": "Einmalkauf", "year": "Jahr", + "billing_per_event": "pro Event", + "billing_per_kontingent": "pro Kontingent", + "available": "Verfügbar", + "not_available": "Nicht verfügbar", + "standard_support": "Standard-Support", + "priority_support": "Priorisierter Support", + "badge_best_value": "Bestes Preis‑Leistungs‑Verhältnis", + "badge_most_popular": "Beliebt", + "badge_starter": "Start", + "view_details": "Details anzeigen", + "included_package_label": "Inklusive Event-Level", + "recommended_usage_label": "Empfehlung", "max_photos": "Fotos", "max_guests": "Gäste", "gallery_days": "Tage Galerie", - "max_events_year": "Events/Jahr", + "max_events_year": "Events enthalten", + "recommended_usage_window": "Empfohlen innerhalb von 24 Monaten zu nutzen.", "buy_now": "Jetzt kaufen", - "subscribe_now": "Jetzt abonnieren", + "subscribe_now": "Jetzt kaufen", "register_buy": "Registrieren und kaufen", - "register_subscribe": "Registrieren und abonnieren", + "register_subscribe": "Registrieren und kaufen", "faq_title": "Häufige Fragen zu Packages", "faq_q1": "Was ist ein Package?", "faq_a1": "Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.", @@ -93,7 +106,7 @@ "feature_custom_branding": "Benutzerdefiniertes Branding", "feature_advanced_reporting": "Erweiterte Berichterstattung", "for_endcustomers": "Für Endkunden", - "for_resellers": "Für Reseller", + "for_resellers": "Für Partner / Agenturen", "details_show": "Details anzeigen", "comparison_title": "Packages vergleichen", "price": "Preis", @@ -104,10 +117,10 @@ "no_watermark": "Kein Wasserzeichen", "custom_branding": "Benutzerdefiniertes Branding", "max_tenants": "Max. Tenants", - "max_events": "Max. Events/Jahr", + "max_events": "Events im Kontingent", "faq_free": "Was ist das Free Package?", "faq_upgrade": "Kann ich upgraden?", - "faq_reseller": "Was für Reseller?", + "faq_reseller": "Was für Partner / Agenturen?", "faq_payment": "Zahlung sicher?" }, "blog": { diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index 5bb8018..1a58d07 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -7,21 +7,21 @@ return [ 'hero_description' => 'Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.', 'cta_explore' => 'Packages entdecken', 'tab_endcustomer' => 'Endkunden', - 'tab_reseller' => 'Reseller & Agenturen', + 'tab_reseller' => 'Partner / Agenturen', 'section_endcustomer' => 'Packages für Endkunden (Einmalkauf pro Event)', - 'section_reseller' => 'Packages für Reseller (Jährliches Abo)', + 'section_reseller' => 'Packages für Partner / Agenturen (Event-Kontingent)', 'free' => 'Kostenlos', 'one_time' => 'Einmalkauf', - 'subscription' => 'Abo', + 'subscription' => 'Event-Kontingent', 'year' => 'Jahr', 'max_photos' => 'Fotos', 'max_guests' => 'Gäste', 'gallery_days' => 'Tage Galerie', - 'max_events_year' => 'Events/Jahr', + 'max_events_year' => 'Events enthalten', 'buy_now' => 'Jetzt kaufen', - 'subscribe_now' => 'Jetzt abonnieren', + 'subscribe_now' => 'Event-Kontingent kaufen', 'register_buy' => 'Registrieren und kaufen', - 'register_subscribe' => 'Registrieren und abonnieren', + 'register_subscribe' => 'Registrieren und kaufen', 'faq_title' => 'Häufige Fragen zu Packages', 'faq_q1' => 'Was ist ein Package?', 'faq_a1' => 'Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.', @@ -49,7 +49,7 @@ return [ 'feature_limited_sharing' => 'Begrenztes Teilen', 'feature_no_branding' => 'Kein Branding', 'feature_0' => 'Basis-Feature', - 'feature_reseller_dashboard' => 'Reseller-Dashboard', + 'feature_reseller_dashboard' => 'Partner-Dashboard', 'feature_custom_branding' => 'Benutzerdefiniertes Branding', 'feature_advanced_reporting' => 'Erweiterte Berichterstattung', 'badge_most_popular' => 'Beliebteste Wahl', @@ -57,10 +57,12 @@ return [ 'badge_starter' => 'Perfekt für den Start', 'billing_per_event' => 'pro Event', 'billing_per_year' => 'pro Jahr', + 'billing_per_kontingent' => 'pro Kontingent', 'more_features' => '+:count weitere Features', 'max_photos_label' => 'Max. Fotos', 'max_guests_label' => 'Max. Gäste', 'gallery_days_label' => 'Galerie-Tage', + 'recommended_usage_window' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.', 'feature_overview' => 'Feature-Überblick', 'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.', 'features_label' => 'Features', @@ -109,7 +111,7 @@ return [ 'summary_title' => 'Ihre Bestellung', 'package_label' => 'Ausgewähltes Package', 'billing_type_one_time' => 'Einmalkauf (pro Event)', - 'billing_type_subscription' => 'Abo (wiederkehrend)', + 'billing_type_subscription' => 'Einmalkauf (Kontingent)', 'legal_links_intro' => 'Mit Abschluss des Kaufs akzeptieren Sie unsere', 'link_terms' => 'AGB', 'link_privacy' => 'Datenschutzerklärung', @@ -118,7 +120,7 @@ return [ 'checkbox_terms_error' => 'Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.', 'checkbox_digital_content_label' => 'Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.', 'checkbox_digital_content_error' => 'Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.', - 'hint_subscription_withdrawal' => 'Bei Abonnements haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.', + 'hint_subscription_withdrawal' => 'Bei Einmalkäufen haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.', ], 'legal' => [ 'imprint' => 'Impressum', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 3345808..7386d40 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -46,7 +46,7 @@ return [ 'emotion' => 'Emotion', 'event_type' => 'Event Type', 'last_activity' => 'Last activity', - 'credits' => 'Credits', + 'credits' => 'Event kontingent', 'settings' => 'Settings', 'join' => 'Join', 'unnamed' => 'Unnamed', @@ -503,7 +503,7 @@ return [ 'heading' => 'Uploads (14 days)', ], 'credit_alerts' => [ - 'low_balance_label' => 'Tenants with low credits', + 'low_balance_label' => 'Tenants with low event kontingent', 'low_balance_desc' => 'May require follow-up', 'monthly_revenue_label' => 'Revenue (month)', 'monthly_revenue_desc' => 'Current month (:month)', @@ -532,10 +532,10 @@ return [ 'name' => 'Tenant name', 'slug' => 'Slug', 'contact_email' => 'Contact email', - 'event_credits_balance' => 'Event credits balance', + 'event_credits_balance' => 'Event kontingent', 'features' => 'Features', 'total_revenue' => 'Total revenue', - 'active_reseller_package' => 'Active reseller package', + 'active_reseller_package' => 'Active partner / agency package', 'remaining_events' => 'Remaining events', 'package_expires_at' => 'Package expires at', 'is_active' => 'Active', @@ -560,12 +560,12 @@ return [ 'timeline' => 'Audit timeline', ], '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' => 'Adjust kontingent', + 'adjust_credits_delta' => 'Event kontingent delta (positive/negative)', + 'adjust_credits_delta_hint' => 'Positive values add kontingent, negative values deduct it.', 'adjust_credits_reason' => 'Internal note', - 'adjust_credits_success_title' => 'Credits updated', - 'adjust_credits_success_body' => 'Credits changed by :delta. New balance: :balance.', + 'adjust_credits_success_title' => 'Kontingent updated', + 'adjust_credits_success_body' => 'Kontingent changed by :delta. New balance: :balance.', 'lifecycle' => 'Lifecycle', 'activate' => 'Activate', 'deactivate' => 'Deactivate', @@ -649,7 +649,7 @@ return [ 'fields' => [ 'tenant' => 'Tenant', 'package' => 'Package', - 'credits' => 'Credits', + 'credits' => 'Event kontingent', 'price' => 'Price', 'currency' => 'Currency', 'platform' => 'Platform', diff --git a/resources/lang/en/api.php b/resources/lang/en/api.php index e09eba4..ec438e4 100644 --- a/resources/lang/en/api.php +++ b/resources/lang/en/api.php @@ -14,4 +14,38 @@ return [ 'default_title' => 'Access denied', 'default_message' => 'We could not grant access with this QR link.', ], + 'packages' => [ + 'event_tier_unavailable' => [ + 'title' => 'Selected tier unavailable', + 'message' => 'No Event-Kontingent is available for the selected event tier. Choose a different tier or purchase the matching Event-Kontingent.', + ], + 'event_limit_exceeded' => [ + 'title' => 'Event-Kontingent depleted', + 'message' => 'Your current Event-Kontingent has no remaining events. Purchase another Event-Kontingent to create new events.', + ], + 'event_limit_missing' => [ + 'title' => 'No package assigned', + 'message' => 'Purchase an Event-Kontingent to create events.', + ], + 'event_not_found' => [ + 'title' => 'Event not accessible', + 'message' => 'The selected event could not be found or belongs to another tenant.', + ], + 'event_package_missing' => [ + 'title' => 'Event package missing', + 'message' => 'No package is attached to this event. Assign a package to enable uploads.', + ], + 'photo_limit_exceeded' => [ + 'title' => 'Photo upload limit reached', + 'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.', + ], + 'tenant_photo_limit_exceeded' => [ + 'title' => 'Tenant photo limit reached', + 'message' => 'This tenant has reached its photo allowance for the event.', + ], + 'tenant_storage_limit_exceeded' => [ + 'title' => 'Tenant storage limit reached', + 'message' => 'This tenant has reached its storage allowance.', + ], + ], ]; diff --git a/resources/lang/en/marketing.json b/resources/lang/en/marketing.json index 3c39122..822e160 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -47,21 +47,34 @@ "hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.", "cta_explore": "Discover Packages", "tab_endcustomer": "End Customers", - "tab_reseller": "Resellers & Agencies", + "tab_reseller": "Partner / Agency", "section_endcustomer": "Packages for End Customers (One-time purchase per Event)", - "section_reseller": "Packages for Resellers (Annual Subscription)", + "section_reseller": "Packages for Partner / Agencies (Event-Kontingent)", "free": "Free", "one_time": "One-time purchase", - "subscription": "Subscription", + "subscription": "One-time purchase", "year": "Year", + "billing_per_event": "per event", + "billing_per_kontingent": "per bundle", + "available": "Available", + "not_available": "Not available", + "standard_support": "Standard support", + "priority_support": "Priority support", + "badge_best_value": "Best value", + "badge_most_popular": "Most popular", + "badge_starter": "Starter", + "view_details": "View details", + "included_package_label": "Included event tier", + "recommended_usage_label": "Recommendation", "max_photos": "Photos", "max_guests": "Guests", "gallery_days": "Gallery Days", - "max_events_year": "Events/Year", + "max_events_year": "Events included", + "recommended_usage_window": "Recommended to use within 24 months.", "buy_now": "Buy Now", - "subscribe_now": "Subscribe Now", + "subscribe_now": "Buy Now", "register_buy": "Register and Buy", - "register_subscribe": "Register and Subscribe", + "register_subscribe": "Register and Buy", "faq_title": "Frequently Asked Questions about Packages", "faq_q1": "What is a Package?", "faq_a1": "A Package defines limits and features for your event, e.g. number of photos and gallery duration.", @@ -93,7 +106,7 @@ "feature_custom_branding": "Custom Branding", "feature_advanced_reporting": "Advanced Reporting", "for_endcustomers": "For End Customers", - "for_resellers": "For Resellers", + "for_resellers": "For Partner / Agencies", "details_show": "Show Details", "comparison_title": "Compare Packages", "price": "Price", @@ -104,10 +117,10 @@ "no_watermark": "No Watermark", "custom_branding": "Custom Branding", "max_tenants": "Max. Tenants", - "max_events": "Max. Events/Year", + "max_events": "Events in kontingent", "faq_free": "What is the Free Package?", "faq_upgrade": "Can I upgrade?", - "faq_reseller": "What for Resellers?", + "faq_reseller": "What for Partner / Agencies?", "faq_payment": "Payment secure?" }, "blog": { diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index 4f25ae0..5e2e9ce 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -7,21 +7,21 @@ return [ 'hero_description' => 'From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.', 'cta_explore' => 'Discover Packages', 'tab_endcustomer' => 'End Customers', - 'tab_reseller' => 'Resellers & Agencies', + 'tab_reseller' => 'Partner / Agencies', 'section_endcustomer' => 'Packages for End Customers (One-time purchase per Event)', - 'section_reseller' => 'Packages for Resellers (Annual Subscription)', + 'section_reseller' => 'Packages for Partner / Agencies (Event kontingent)', 'free' => 'Free', 'one_time' => 'One-time purchase', - 'subscription' => 'Subscription', + 'subscription' => 'Event kontingent', 'year' => 'Year', 'max_photos' => 'Photos', 'max_guests' => 'Guests', 'gallery_days' => 'Gallery Days', - 'max_events_year' => 'Events/Year', + 'max_events_year' => 'Events included', 'buy_now' => 'Buy Now', - 'subscribe_now' => 'Subscribe Now', + 'subscribe_now' => 'Buy event kontingent', 'register_buy' => 'Register and Buy', - 'register_subscribe' => 'Register and Subscribe', + 'register_subscribe' => 'Register and buy', 'faq_title' => 'Frequently Asked Questions about Packages', 'faq_q1' => 'What is a Package?', 'faq_a1' => 'A Package defines limits and features for your event, e.g. number of photos and gallery duration.', @@ -49,7 +49,7 @@ return [ 'feature_limited_sharing' => 'Limited Sharing', 'feature_no_branding' => 'No Branding', 'feature_0' => 'Basic Feature', - 'feature_reseller_dashboard' => 'Reseller Dashboard', + 'feature_reseller_dashboard' => 'Partner dashboard', 'feature_custom_branding' => 'Custom Branding', 'feature_advanced_reporting' => 'Advanced Reporting', 'badge_most_popular' => 'Most Popular', @@ -57,6 +57,8 @@ return [ 'badge_starter' => 'Perfect Starter', 'billing_per_event' => 'per event', 'billing_per_year' => 'per year', + 'billing_per_kontingent' => 'per bundle', + 'recommended_usage_window' => 'Recommended to use within 24 months.', 'more_features' => '+:count more features', 'max_photos_label' => 'Max. photos', 'max_guests_label' => 'Max. guests', @@ -109,7 +111,7 @@ return [ 'summary_title' => 'Your order', 'package_label' => 'Selected package', 'billing_type_one_time' => 'One-time purchase (per event)', - 'billing_type_subscription' => 'Subscription (recurring)', + 'billing_type_subscription' => 'One-time purchase (kontingent)', 'legal_links_intro' => 'By completing your order you accept our', 'link_terms' => 'Terms & Conditions', 'link_privacy' => 'Privacy Policy', @@ -118,7 +120,7 @@ return [ 'checkbox_terms_error' => 'Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.', 'checkbox_digital_content_label' => 'I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.', 'checkbox_digital_content_error' => 'Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.', - 'hint_subscription_withdrawal' => 'For subscriptions, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.', + 'hint_subscription_withdrawal' => 'For one-time purchases, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.', ], 'legal' => [ 'imprint' => 'Imprint', diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index c54c727..7b6502a 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -155,13 +155,17 @@ class EventControllerTest extends TenantTestCase { $tenant = $this->tenant; $eventType = EventType::factory()->create(); + $includedPackage = Package::factory()->endcustomer()->create([ + 'slug' => 'standard', + 'gallery_days' => 30, + ]); $package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]); TenantPackage::factory()->create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'used_events' => 0, 'active' => true, - 'expires_at' => now()->addYear(), + 'expires_at' => null, ]); // First event succeeds @@ -175,6 +179,13 @@ class EventControllerTest extends TenantTestCase $response1->assertStatus(201); + $event = Event::where('tenant_id', $tenant->id)->where('slug', 'first-event')->firstOrFail(); + $this->assertDatabaseHas('event_packages', [ + 'event_id' => $event->id, + 'package_id' => $includedPackage->id, + 'purchased_price' => 0.00, + ]); + // Second event fails due to limit $response2 = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [ 'name' => 'Second Event', @@ -188,6 +199,39 @@ class EventControllerTest extends TenantTestCase ->assertJsonPath('error.code', 'event_limit_exceeded'); } + public function test_create_event_rejects_unavailable_service_tier_for_partner_kontingent(): void + { + $tenant = $this->tenant; + $eventType = EventType::factory()->create(); + + Package::factory()->endcustomer()->create(['slug' => 'standard', 'gallery_days' => 30]); + Package::factory()->endcustomer()->create(['slug' => 'pro', 'gallery_days' => 30]); + + $partnerPackage = Package::factory()->reseller()->create([ + 'max_events_per_year' => 5, + 'included_package_slug' => 'standard', + ]); + + TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $partnerPackage->id, + 'used_events' => 0, + 'active' => true, + 'expires_at' => null, + ]); + + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [ + 'name' => 'Premium Event', + 'slug' => 'premium-event', + 'event_date' => Carbon::now()->addDays(10)->toDateString(), + 'event_type_id' => $eventType->id, + 'service_package_slug' => 'pro', + ]); + + $response->assertStatus(402) + ->assertJsonPath('error.code', 'event_tier_unavailable'); + } + public function test_update_event_accepts_live_show_settings(): void { $eventType = EventType::factory()->create(); diff --git a/tests/Unit/Services/PackageLimitEvaluatorTest.php b/tests/Unit/Services/PackageLimitEvaluatorTest.php index 2dbad0f..45e314a 100644 --- a/tests/Unit/Services/PackageLimitEvaluatorTest.php +++ b/tests/Unit/Services/PackageLimitEvaluatorTest.php @@ -34,7 +34,7 @@ class PackageLimitEvaluatorTest extends TestCase 'active' => true, ]); - $violation = $this->evaluator->assessEventCreation($tenant); + $violation = $this->evaluator->assessEventCreation($tenant, null); $this->assertNull($violation); } @@ -57,7 +57,7 @@ class PackageLimitEvaluatorTest extends TestCase $tenant->refresh(); - $violation = $this->evaluator->assessEventCreation($tenant); + $violation = $this->evaluator->assessEventCreation($tenant, null); $this->assertNotNull($violation); $this->assertSame('event_limit_exceeded', $violation['code']); diff --git a/tests/Unit/Services/TenantUsageTrackerTest.php b/tests/Unit/Services/TenantUsageTrackerTest.php index 6092029..85e7ea6 100644 --- a/tests/Unit/Services/TenantUsageTrackerTest.php +++ b/tests/Unit/Services/TenantUsageTrackerTest.php @@ -83,5 +83,6 @@ class TenantUsageTrackerTest extends TestCase $tenantPackage->refresh(); $this->assertNotNull($tenantPackage->event_limit_notified_at); + $this->assertFalse($tenantPackage->active); } } diff --git a/tests/Unit/TenantModelTest.php b/tests/Unit/TenantModelTest.php index 1403b5d..c5b664a 100644 --- a/tests/Unit/TenantModelTest.php +++ b/tests/Unit/TenantModelTest.php @@ -146,4 +146,39 @@ class TenantModelTest extends TestCase $this->assertFalse($tenant->features['analytics']); } + public function test_consume_event_allowance_moves_to_next_credit_batch(): void + { + $tenant = Tenant::factory()->create(); + + $firstPackage = Package::factory()->reseller()->create([ + 'max_events_per_year' => 1, + ]); + + $secondPackage = Package::factory()->reseller()->create([ + 'max_events_per_year' => 1, + ]); + + $firstBatch = TenantPackage::factory()->for($tenant)->for($firstPackage)->create([ + 'used_events' => 0, + 'active' => true, + 'expires_at' => null, + 'purchased_at' => now()->subDay(), + ]); + + $secondBatch = TenantPackage::factory()->for($tenant)->for($secondPackage)->create([ + 'used_events' => 0, + 'active' => true, + 'expires_at' => null, + 'purchased_at' => now(), + ]); + + $this->assertTrue($tenant->consumeEventAllowance()); + $this->assertFalse($firstBatch->fresh()->active); + $this->assertTrue($secondBatch->fresh()->active); + + $this->assertTrue($tenant->consumeEventAllowance()); + $this->assertFalse($secondBatch->fresh()->active); + + $this->assertFalse($tenant->consumeEventAllowance()); + } } diff --git a/tests/Unit/TenantPackageTest.php b/tests/Unit/TenantPackageTest.php index 517416c..d78dbd5 100644 --- a/tests/Unit/TenantPackageTest.php +++ b/tests/Unit/TenantPackageTest.php @@ -30,7 +30,7 @@ class TenantPackageTest extends TestCase $this->assertTrue($tenantPackage->expires_at->lessThanOrEqualTo(now()->addYears(2))); } - public function test_reseller_packages_still_expire(): void + public function test_reseller_packages_do_not_expire_by_default_but_can_be_expired(): void { $package = Package::factory()->reseller()->create(['max_events_per_year' => 5]); @@ -41,8 +41,8 @@ class TenantPackageTest extends TestCase $tenantPackage->refresh(); - $this->assertNotNull($tenantPackage->expires_at); - $this->assertTrue($tenantPackage->expires_at->isFuture()); + $this->assertNull($tenantPackage->expires_at); + $this->assertTrue($tenantPackage->isActive()); $tenantPackage->forceFill(['expires_at' => now()->subDay()])->save();