Update partner packages, copy, and demo switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-15 17:33:36 +01:00
parent 2f93271d94
commit ad829ae509
50 changed files with 1335 additions and 411 deletions

View File

@@ -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 $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) public function __construct(private EventStorageManager $eventStorageManager)
{ {
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
$slugs = [ $slugs = [
'starter' => 'Starter', 'starter' => 'Starter',
'standard' => 'Standard', 'standard' => 'Standard',
's-small-reseller' => 'Reseller S', 's-small-reseller' => 'Partner Start',
]; ];
$packages = []; $packages = [];
@@ -232,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command
private function seedResellerActive(array $packages, array $eventTypes): void private function seedResellerActive(array $packages, array $eventTypes): void
{ {
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
$tenant = $this->upsertTenant( $tenant = $this->upsertTenant(
slug: 'demo-reseller-active', slug: 'demo-reseller-active',
name: 'Demo Reseller Active', name: 'Demo Partner Active',
contactEmail: 'reseller-active@demo.fotospiel', contactEmail: 'partner-active@demo.fotospiel',
attributes: [ attributes: [
'subscription_tier' => 'reseller', 'subscription_tier' => 'reseller',
'subscription_status' => 'active', 'subscription_status' => 'active',
], ],
); );
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel'); $this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
TenantPackage::updateOrCreate( TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
@@ -279,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command
foreach ($events as $index => $config) { foreach ($events as $index => $config) {
$event = $this->upsertEvent( $event = $this->upsertEvent(
tenant: $tenant, tenant: $tenant,
package: $packages['standard'], package: $eventPackage,
eventType: $config['type'], eventType: $config['type'],
attributes: [ attributes: [
'name' => $config['name'], 'name' => $config['name'],
@@ -296,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command
private function seedResellerFull(array $packages, array $eventTypes): void private function seedResellerFull(array $packages, array $eventTypes): void
{ {
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
$tenant = $this->upsertTenant( $tenant = $this->upsertTenant(
slug: 'demo-reseller-full', slug: 'demo-reseller-full',
name: 'Demo Reseller Voll', name: 'Demo Partner Voll',
contactEmail: 'reseller-full@demo.fotospiel', contactEmail: 'partner-full@demo.fotospiel',
attributes: [ attributes: [
'subscription_tier' => 'reseller', 'subscription_tier' => 'reseller',
'subscription_status' => 'active', 'subscription_status' => 'active',
], ],
); );
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel'); $this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
TenantPackage::updateOrCreate( TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
@@ -330,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command
foreach ($eventConfigs as $index => $config) { foreach ($eventConfigs as $index => $config) {
$event = $this->upsertEvent( $event = $this->upsertEvent(
tenant: $tenant, tenant: $tenant,
package: $packages['standard'], package: $eventPackage,
eventType: $config['type'], eventType: $config['type'],
attributes: [ attributes: [
'name' => $config['name'], 'name' => $config['name'],
@@ -435,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command
return $event; 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 private function fallbackEventType(): ?EventType
{ {
$fallback = EventType::first(); $fallback = EventType::first();

View File

@@ -277,13 +277,13 @@ class PackageController extends Controller
'purchased_at' => now(), 'purchased_at' => now(),
]); ]);
} else { } else {
// Reseller subscription // Partner / reseller Event-Kontingent package
\App\Models\TenantPackage::create([ \App\Models\TenantPackage::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
'price' => $package->price, 'price' => $package->price,
'purchased_at' => now(), 'purchased_at' => now(),
'expires_at' => now()->addYear(), 'expires_at' => null,
'active' => true, 'active' => true,
]); ]);
} }

View File

@@ -99,6 +99,9 @@ class EventController extends Controller
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null; $requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']); 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() $tenantPackage = $tenant->tenantPackages()
->with('package') ->with('package')
@@ -116,6 +119,18 @@ class EventController extends Controller
$package = $this->resolveOwnerPackage(); $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) { if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id); $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(); $requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null; $existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
@@ -161,8 +181,8 @@ class EventController extends Controller
unset($eventData['features']); unset($eventData['features']);
} }
$settings['branding_allowed'] = $package->branding_allowed !== false; $settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false;
$settings['watermark_allowed'] = $package->watermark_allowed !== false; $settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false;
$eventData['settings'] = $settings; $eventData['settings'] = $settings;
@@ -190,21 +210,23 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed); $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); $event = Event::create($eventData);
EventPackage::create([ EventPackage::create([
'event_id' => $event->id, 'event_id' => $event->id,
'package_id' => $package->id, 'package_id' => $eventServicePackage->id,
'purchased_price' => $package->price, 'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
'purchased_at' => now(), '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); $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.'); throw new HttpException(402, 'Insufficient package allowance.');
} }
} }
@@ -227,6 +249,47 @@ class EventController extends Controller
], 201); ], 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 private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
{ {
return PackagePurchase::query() return PackagePurchase::query()

View File

@@ -60,6 +60,7 @@ class TenantPackageController extends Controller
$pkg?->limits ?? [], $pkg?->limits ?? [],
$this->buildUsageSnapshot($eventPackage), $this->buildUsageSnapshot($eventPackage),
[ [
'included_package_slug' => $pkg?->included_package_slug,
'branding_allowed' => $pkg?->branding_allowed, 'branding_allowed' => $pkg?->branding_allowed,
'watermark_allowed' => $pkg?->watermark_allowed, 'watermark_allowed' => $pkg?->watermark_allowed,
'features' => $pkg?->features ?? [], 'features' => $pkg?->features ?? [],

View File

@@ -28,7 +28,12 @@ class CreditCheckMiddleware
} }
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) { 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) { if ($violation !== null) {
return ApiError::response( return ApiError::response(

View File

@@ -73,7 +73,12 @@ class PackageMiddleware
private function detectViolation(Request $request, Tenant $tenant): ?array private function detectViolation(Request $request, Tenant $tenant): ?array
{ {
if ($request->routeIs('api.v1.tenant.events.store')) { 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')) { if ($request->routeIs('api.v1.tenant.events.photos.store')) {

View File

@@ -31,6 +31,12 @@ class EventStoreRequest extends FormRequest
'location' => ['nullable', 'string', 'max:255'], 'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'], 'event_type_id' => ['required', 'exists:event_types,id'],
'package_id' => ['nullable', 'integer', 'exists:packages,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'], 'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
'public_url' => ['nullable', 'url', 'max:500'], 'public_url' => ['nullable', 'url', 'max:500'],
'custom_domain' => ['nullable', 'string', 'max:255'], 'custom_domain' => ['nullable', 'string', 'max:255'],

View File

@@ -19,6 +19,7 @@ class Package extends Model
'name_translations', 'name_translations',
'slug', 'slug',
'type', 'type',
'included_package_slug',
'price', 'price',
'max_photos', 'max_photos',
'max_guests', 'max_guests',

View File

@@ -100,7 +100,14 @@ class Tenant extends Model
public function activeResellerPackage(): HasOne 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 public function notificationLogs(): HasMany
@@ -151,6 +158,13 @@ class Tenant extends Model
return false; 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 public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
{ {
$package = $this->getActiveResellerPackage(); $package = $this->getActiveResellerPackage();
@@ -183,13 +197,68 @@ class Tenant extends Model
return false; 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 public function getActiveResellerPackage(): ?TenantPackage
{ {
return $this->activeResellerPackage() return $this->activeResellerPackage()->with('package')->first();
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) }
public function getActiveResellerPackageFor(?string $includedPackageSlug): ?TenantPackage
{
$query = $this->tenantPackages()
->with('package')
->where('active', true) ->where('active', true)
->orderByDesc('expires_at') ->where(function ($query) {
->first(); $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 public function activeSubscription(): Attribute

View File

@@ -66,18 +66,30 @@ class TenantPackage extends Model
return false; 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; return $this->used_events < $maxEvents;
} }
public function getRemainingEventsAttribute(): int public function getRemainingEventsAttribute(): ?int
{ {
if (! $this->package->isReseller()) { if (! $this->package->isReseller()) {
return 0; 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); return max(0, $max - $this->used_events);
} }
@@ -94,9 +106,7 @@ class TenantPackage extends Model
$package = $tenantPackage->package; $package = $tenantPackage->package;
if ($package && $package->isReseller()) { if ($package && $package->isReseller()) {
if (! $tenantPackage->expires_at) { // Reseller packages represent prepaid Event-Kontingente and should not expire by default.
$tenantPackage->expires_at = now()->addYear();
}
} elseif (! $tenantPackage->expires_at) { } elseif (! $tenantPackage->expires_at) {
$tenantPackage->expires_at = now()->addYear(); $tenantPackage->expires_at = now()->addYear();
} }

View File

@@ -94,18 +94,34 @@ class CheckoutAssignmentService
] ]
); );
$tenantPackage = TenantPackage::updateOrCreate( if ($package->type === 'reseller') {
[ $tenantPackage = null;
'tenant_id' => $tenant->id,
'package_id' => $package->id, if ($purchase->wasRecentlyCreated) {
], $tenantPackage = TenantPackage::create([
[ 'tenant_id' => $tenant->id,
'price' => round($price, 2), 'package_id' => $package->id,
'active' => true, 'price' => round($price, 2),
'purchased_at' => now(), 'active' => true,
'expires_at' => $this->resolveExpiry($package, $tenant), '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') { if ($package->type !== 'reseller') {
$tenant->forceFill([ $tenant->forceFill([
@@ -188,11 +204,7 @@ class CheckoutAssignmentService
protected function resolveExpiry(Package $package, Tenant $tenant) protected function resolveExpiry(Package $package, Tenant $tenant)
{ {
if ($package->type === 'reseller') { if ($package->type === 'reseller') {
$hasActive = TenantPackage::where('tenant_id', $tenant->id) return null;
->where('active', true)
->exists();
return $hasActive ? now()->addYear() : now()->addDays(14);
} }
return now()->addYear(); return now()->addYear();

View File

@@ -11,7 +11,7 @@ class PackageLimitEvaluator
{ {
public function __construct(private readonly TenantUsageService $tenantUsageService) {} 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() $hasEndcustomerPackage = $tenant->tenantPackages()
->where('active', true) ->where('active', true)
@@ -22,17 +22,66 @@ class PackageLimitEvaluator
return null; return null;
} }
if ($tenant->hasEventAllowance()) { if ($tenant->hasEventAllowanceFor($includedPackageSlug)) {
return null; return null;
} }
$package = $tenant->getActiveResellerPackage(); $package = $tenant->getActiveResellerPackageFor($includedPackageSlug);
if (! $package) { 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 [ return [
'code' => 'event_limit_missing', 'code' => 'event_limit_missing',
'title' => 'No package assigned', 'title' => __('api.packages.event_limit_missing.title'),
'message' => 'Assign a package or addon to create events.', 'message' => __('api.packages.event_limit_missing.message'),
'status' => 402, 'status' => 402,
'meta' => [ 'meta' => [
'scope' => 'events', 'scope' => 'events',
@@ -49,8 +98,8 @@ class PackageLimitEvaluator
return [ return [
'code' => 'event_limit_exceeded', 'code' => 'event_limit_exceeded',
'title' => 'Event quota reached', 'title' => __('api.packages.event_limit_exceeded.title'),
'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.', 'message' => __('api.packages.event_limit_exceeded.message'),
'status' => 402, 'status' => 402,
'meta' => [ 'meta' => [
'scope' => 'events', 'scope' => 'events',
@@ -74,8 +123,8 @@ class PackageLimitEvaluator
if (! $event) { if (! $event) {
return [ return [
'code' => 'event_not_found', 'code' => 'event_not_found',
'title' => 'Event not accessible', 'title' => __('api.packages.event_not_found.title'),
'message' => 'The selected event could not be found or belongs to another tenant.', 'message' => __('api.packages.event_not_found.message'),
'status' => 404, 'status' => 404,
'meta' => [ 'meta' => [
'scope' => 'photos', 'scope' => 'photos',
@@ -87,8 +136,8 @@ class PackageLimitEvaluator
if (! $eventPackage || ! $eventPackage->package) { if (! $eventPackage || ! $eventPackage->package) {
return [ return [
'code' => 'event_package_missing', 'code' => 'event_package_missing',
'title' => 'Event package missing', 'title' => __('api.packages.event_package_missing.title'),
'message' => 'No package is attached to this event. Assign a package to enable uploads.', 'message' => __('api.packages.event_package_missing.message'),
'status' => 409, 'status' => 409,
'meta' => [ 'meta' => [
'scope' => 'photos', 'scope' => 'photos',
@@ -102,8 +151,8 @@ class PackageLimitEvaluator
if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) { if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) {
return [ return [
'code' => 'photo_limit_exceeded', 'code' => 'photo_limit_exceeded',
'title' => 'Photo upload limit reached', 'title' => __('api.packages.photo_limit_exceeded.title'),
'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.', 'message' => __('api.packages.photo_limit_exceeded.message'),
'status' => 402, 'status' => 402,
'meta' => [ 'meta' => [
'scope' => 'photos', 'scope' => 'photos',
@@ -122,8 +171,8 @@ class PackageLimitEvaluator
if ($eventPackage->used_photos >= $tenantPhotoLimit) { if ($eventPackage->used_photos >= $tenantPhotoLimit) {
return [ return [
'code' => 'tenant_photo_limit_exceeded', 'code' => 'tenant_photo_limit_exceeded',
'title' => 'Tenant photo limit reached', 'title' => __('api.packages.tenant_photo_limit_exceeded.title'),
'message' => 'This tenant has reached its photo allowance for the event.', 'message' => __('api.packages.tenant_photo_limit_exceeded.message'),
'status' => 402, 'status' => 402,
'meta' => [ 'meta' => [
'scope' => 'photos', 'scope' => 'photos',
@@ -146,8 +195,8 @@ class PackageLimitEvaluator
if ($projectedBytes >= $storageLimitBytes) { if ($projectedBytes >= $storageLimitBytes) {
return [ return [
'code' => 'tenant_storage_limit_exceeded', 'code' => 'tenant_storage_limit_exceeded',
'title' => 'Tenant storage limit reached', 'title' => __('api.packages.tenant_storage_limit_exceeded.title'),
'message' => 'This tenant has reached its storage allowance.', 'message' => __('api.packages.tenant_storage_limit_exceeded.message'),
'status' => 402, 'status' => 402,
'meta' => [ 'meta' => [
'scope' => 'storage', 'scope' => 'storage',

View File

@@ -4,7 +4,6 @@ namespace App\Services\Packages;
use App\Events\Packages\TenantPackageEventLimitReached; use App\Events\Packages\TenantPackageEventLimitReached;
use App\Events\Packages\TenantPackageEventThresholdReached; use App\Events\Packages\TenantPackageEventThresholdReached;
use App\Models\Tenant;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
@@ -63,6 +62,12 @@ class TenantUsageTracker
} }
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit)); $this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
if ($tenantPackage->active) {
$tenantPackage->forceFill([
'active' => false,
])->save();
}
} }
} }
} }

View File

@@ -43,6 +43,7 @@ trait PresentsPackages
'name' => $name, 'name' => $name,
'slug' => $package->slug, 'slug' => $package->slug,
'type' => $package->type, 'type' => $package->type,
'included_package_slug' => $package->included_package_slug,
'price' => $package->price, 'price' => $package->price,
'paddle_product_id' => $package->paddle_product_id, 'paddle_product_id' => $package->paddle_product_id,
'paddle_price_id' => $package->paddle_price_id, 'paddle_price_id' => $package->paddle_price_id,

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('packages', function (Blueprint $table) {
if (! Schema::hasColumn('packages', 'included_package_slug')) {
$table->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');
}
});
}
};

View File

@@ -116,111 +116,149 @@ TEXT,
], ],
[ [
'slug' => 's-small-reseller', 'slug' => 's-small-reseller',
'name' => 'Reseller S', 'name' => 'Partner Start',
'name_translations' => [ 'name_translations' => [
'de' => 'Reseller S', 'de' => 'Partner Start',
'en' => 'Reseller S', 'en' => 'Partner Start',
], ],
'type' => PackageType::RESELLER, 'type' => PackageType::RESELLER,
'included_package_slug' => 'starter',
'price' => 149.00, 'price' => 149.00,
'max_photos' => 1000, 'max_photos' => null,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => 30, 'gallery_days' => null,
'max_tasks' => null, 'max_tasks' => null,
'watermark_allowed' => true, 'watermark_allowed' => true,
'branding_allowed' => true, 'branding_allowed' => true,
'max_events_per_year' => 5, 'max_events_per_year' => 5,
'expires_after' => now()->copy()->addYear(), 'expires_after' => null,
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'], 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y', 'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y',
'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy', 'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy',
'description' => <<<'TEXT' '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 r Partner / Agenturen: {{max_events_per_year}} Events auf StarterNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT, TEXT,
'description_translations' => [ '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.', 'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StarterNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'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.', 'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Starter level. Recommended to use within 24 months.',
], ],
'description_table' => [ 'description_table' => [
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Galerie', 'value' => '{{gallery_duration}}'], ['title' => 'Inklusive Event-Level', 'value' => 'Starter'],
['title' => 'Branding', 'value' => 'Logo & Farben pro Event'], ['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
], ],
], ],
[ [
'slug' => 'm-medium-reseller', 'slug' => 'm-medium-reseller',
'name' => 'Reseller M', 'name' => 'Partner Standard',
'name_translations' => [ 'name_translations' => [
'de' => 'Reseller M', 'de' => 'Partner Standard',
'en' => 'Reseller M', 'en' => 'Partner Standard',
], ],
'type' => PackageType::RESELLER, 'type' => PackageType::RESELLER,
'included_package_slug' => 'standard',
'price' => 349.00, 'price' => 349.00,
'max_photos' => 1500, 'max_photos' => null,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => 60, 'gallery_days' => null,
'max_tasks' => null, 'max_tasks' => null,
'watermark_allowed' => true, 'watermark_allowed' => true,
'branding_allowed' => true, 'branding_allowed' => true,
'max_events_per_year' => 15, 'max_events_per_year' => 15,
'expires_after' => now()->copy()->addYear(), 'expires_after' => null,
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'], 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q', 'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v', 'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
'description' => <<<'TEXT' '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 StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT, TEXT,
'description_translations' => [ '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.', 'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'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.', 'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
], ],
'description_table' => [ 'description_table' => [
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Galerie', 'value' => '{{gallery_duration}}'], ['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
['title' => 'Reporting', 'value' => 'Erweiterte Auswertungen'], ['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
], ],
], ],
[ [
'slug' => 'l-large-reseller', 'slug' => 'l-large-reseller',
'name' => 'Reseller L', 'name' => 'Partner Premium',
'name_translations' => [ 'name_translations' => [
'de' => 'Reseller L', 'de' => 'Partner Premium',
'en' => 'Reseller L', 'en' => 'Partner Premium',
], ],
'type' => PackageType::RESELLER, 'type' => PackageType::RESELLER,
'price' => 699.00, 'included_package_slug' => 'pro',
'max_photos' => 3000, 'price' => 1999.00,
'max_photos' => null,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => 90, 'gallery_days' => null,
'max_tasks' => null, 'max_tasks' => null,
'watermark_allowed' => false, 'watermark_allowed' => false,
'branding_allowed' => true, 'branding_allowed' => true,
'max_events_per_year' => 40, 'max_events_per_year' => 35,
'expires_after' => now()->copy()->addYear(), 'expires_after' => null,
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'], 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'],
'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz', 'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz',
'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z', 'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z',
'description' => <<<'TEXT' '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 r Partner / Agenturen: {{max_events_per_year}} Events auf PremiumNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT, TEXT,
'description_translations' => [ '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.', 'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf PremiumNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'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.', 'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.',
], ],
'description_table' => [ 'description_table' => [
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Branding', 'value' => 'White-Label & eigene Domains'], ['title' => 'Inklusive Event-Level', 'value' => 'Premium'],
['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Features'], ['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 PremiumNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT,
'description_translations' => [
'de' => 'Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf PremiumNiveau. 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', 'slug' => 'studio-annual',
'name' => 'Studio Jahrespaket', 'name' => 'Partner Jahreskontingent (24 Events)',
'name_translations' => [ 'name_translations' => [
'de' => 'Studio Jahrespaket', 'de' => 'Partner Jahreskontingent (24 Events)',
'en' => 'Studio Annual', 'en' => 'Partner annual kontingent (24 events)',
], ],
'type' => PackageType::RESELLER, 'type' => PackageType::RESELLER,
'included_package_slug' => 'standard',
'price' => 1299.00, 'price' => 1299.00,
'max_photos' => null, 'max_photos' => null,
'max_guests' => null, 'max_guests' => null,
@@ -230,42 +268,20 @@ TEXT,
'branding_allowed' => false, 'branding_allowed' => false,
'max_events_per_year' => 24, 'max_events_per_year' => 24,
'expires_after' => null, '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_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06', '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' '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 StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT, TEXT,
'description_translations' => [ '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.', 'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'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.', 'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
], ],
'description_table' => [ 'description_table' => [
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], ['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Branding', 'value' => 'Eigene Subdomain oder App'], ['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
['title' => 'Support', 'value' => 'Persönliche Betreuung'], ['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
], ],
], ],
]; ];
@@ -279,5 +295,7 @@ TEXT,
]) ])
); );
} }
Package::where('slug', 'enterprise-unlimited')->delete();
} }
} }

View File

@@ -91,18 +91,18 @@
"title": "Unsere Packages", "title": "Unsere Packages",
"price": "Preis", "price": "Preis",
"features": "Features", "features": "Features",
"subscription_annual": "Jährliches Abonnement", "subscription_annual": "Event-Kontingent",
"auto_renew": "automatische Verlängerung", "auto_renew": "automatische Verlängerung",
"cancel_anytime": "kündbar jederzeit", "cancel_anytime": "kündbar jederzeit",
"trial_start": "Kostenloser Trial für :days Tage", "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", "unlimited_events": "Unbegrenzte Events",
"custom_branding": "Benutzerdefiniertes Branding", "custom_branding": "Benutzerdefiniertes Branding",
"available": "Verfügbar", "available": "Verfügbar",
"not_available": "Nicht verfügbar", "not_available": "Nicht verfügbar",
"standard_support": "Standard-Support", "standard_support": "Standard-Support",
"priority_support": "Priorisierter Support", "priority_support": "Priorisierter Support",
"cancel_link": "Abo kündigen: :link", "cancel_link": "Paket verwalten: :link",
"hero_title": "Entdecken Sie unsere flexiblen Packages", "hero_title": "Entdecken Sie unsere flexiblen Packages",
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.", "hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
"hero_secondary": "Teste den kompletten Gäste-Flow in unserer Live-Demo kein Login, kein App-Store.", "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": "Pakete entdecken",
"cta_explore_highlight": "Lieblingspaket sichern", "cta_explore_highlight": "Lieblingspaket sichern",
"tab_endcustomer": "Endkunden", "tab_endcustomer": "Endkunden",
"tab_reseller": "Reseller & Agenturen", "tab_reseller": "Partner / Agentur",
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)", "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", "free": "Kostenlos",
"one_time": "Einmalkauf", "one_time": "Einmalkauf",
"subscription": "Abo", "subscription": "Event-Kontingent",
"year": "Jahr", "year": "Jahr",
"max_photos": "Fotos", "max_photos": "Fotos",
"max_guests": "Gäste", "max_guests": "Gäste",
"gallery_days": "Tage Galerie", "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", "buy_now": "Jetzt kaufen",
"subscribe_now": "Jetzt abonnieren", "subscribe_now": "Event-Kontingent kaufen",
"register_buy": "Registrieren und kaufen", "register_buy": "Registrieren und kaufen",
"register_subscribe": "Registrieren und abonnieren", "register_subscribe": "Registrieren und kaufen",
"faq_title": "Häufige Fragen zu Packages", "faq_title": "Häufige Fragen zu Packages",
"faq_lead": "Antworten auf die wichtigsten Fragen mehr Details findest du im Guide „So funktionierts“.", "faq_lead": "Antworten auf die wichtigsten Fragen mehr Details findest du im Guide „So funktionierts“.",
"faq_q1": "Was ist ein Package?", "faq_q1": "Was ist ein Package?",
@@ -153,7 +156,7 @@
"feature_limited_sharing": "Begrenztes Teilen", "feature_limited_sharing": "Begrenztes Teilen",
"feature_no_branding": "Kein Branding", "feature_no_branding": "Kein Branding",
"feature_0": "Basis-Feature", "feature_0": "Basis-Feature",
"feature_reseller_dashboard": "Reseller-Dashboard", "feature_reseller_dashboard": "Partner-Dashboard",
"feature_custom_branding": "Benutzerdefiniertes Branding", "feature_custom_branding": "Benutzerdefiniertes Branding",
"feature_advanced_reporting": "Erweiterte Berichterstattung", "feature_advanced_reporting": "Erweiterte Berichterstattung",
"badge_most_popular": "Beliebteste Wahl", "badge_most_popular": "Beliebteste Wahl",
@@ -161,6 +164,7 @@
"badge_starter": "Perfekt für den Start", "badge_starter": "Perfekt für den Start",
"billing_per_event": "pro Event", "billing_per_event": "pro Event",
"billing_per_year": "pro Jahr", "billing_per_year": "pro Jahr",
"billing_per_kontingent": "pro Kontingent",
"more_features": "+{{count}} weitere Features", "more_features": "+{{count}} weitere Features",
"feature_overview": "Feature-Überblick", "feature_overview": "Feature-Überblick",
"order_hint": "Sofort startklar keine versteckten Kosten, sichere Zahlung über Paddle.", "order_hint": "Sofort startklar keine versteckten Kosten, sichere Zahlung über Paddle.",
@@ -173,7 +177,7 @@
"tasks": "Aufgaben", "tasks": "Aufgaben",
"gallery": "Galerie", "gallery": "Galerie",
"branding": "Branding", "branding": "Branding",
"events_per_year": "Events pro Jahr" "events_per_year": "Events enthalten"
}, },
"more_details_tab": "Mehr Details", "more_details_tab": "Mehr Details",
"quick_facts": "Schnelle Fakten", "quick_facts": "Schnelle Fakten",
@@ -185,7 +189,7 @@
"limits_label": "Limits & Kapazitäten", "limits_label": "Limits & Kapazitäten",
"limits_label_hint": "Alle Kennzahlen auf einen Blick ideal für Planung und Freigaben.", "limits_label_hint": "Alle Kennzahlen auf einen Blick ideal für Planung und Freigaben.",
"for_endcustomers": "Für Endkunden", "for_endcustomers": "Für Endkunden",
"for_resellers": "Für Reseller", "for_resellers": "Für Partner / Agenturen",
"view_details": "Details ansehen", "view_details": "Details ansehen",
"details_show": "Details anzeigen", "details_show": "Details anzeigen",
"comparison_title": "Packages vergleichen", "comparison_title": "Packages vergleichen",
@@ -199,14 +203,14 @@
"watermark_label": "Wasserzeichen", "watermark_label": "Wasserzeichen",
"no_watermark": "Kein Wasserzeichen", "no_watermark": "Kein Wasserzeichen",
"max_tenants": "Max. Tenants", "max_tenants": "Max. Tenants",
"max_events": "Max. Events/Jahr", "max_events": "Events enthalten",
"faq_free": "Was ist das Free Package?", "faq_free": "Was ist das Free Package?",
"faq_upgrade": "Kann ich upgraden?", "faq_upgrade": "Kann ich upgraden?",
"faq_reseller": "Was für Reseller?", "faq_reseller": "Was für Partner / Agenturen?",
"faq_payment": "Zahlung sicher?", "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_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_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.", "faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Paddle abgewickelt. Ihre Daten sind GDPR-konform geschützt.",
"testimonials": { "testimonials": {
"anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.", "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.", "purchase_complete_desc": "Melden Sie sich an, um fortzufahren.",
"login": "Anmelden", "login": "Anmelden",
"no_account": "Kein Konto? Registrieren", "no_account": "Kein Konto? Registrieren",
"manage_subscription": "Abo verwalten", "manage_subscription": "Kontingent verwalten",
"stripe_dashboard": "Stripe-Dashboard", "stripe_dashboard": "Stripe-Dashboard",
"trial_activated": "Trial aktiviert für 14 Tage!" "trial_activated": "Trial aktiviert für 14 Tage!"
}, },
@@ -488,7 +492,7 @@
"summary_title": "Ihre Bestellung", "summary_title": "Ihre Bestellung",
"package_label": "Ausgewähltes Paket", "package_label": "Ausgewähltes Paket",
"billing_type_one_time": "Einmalkauf (pro Event)", "billing_type_one_time": "Einmalkauf (pro Event)",
"billing_type_subscription": "Abo (wiederkehrend)", "billing_type_subscription": "Einmalkauf (Kontingent)",
"legal_links_intro": "Details zur Belehrung:", "legal_links_intro": "Details zur Belehrung:",
"link_terms": "AGB", "link_terms": "AGB",
"link_privacy": "Datenschutzerklärung", "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_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_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.", "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", "open_withdrawal": "Widerrufsbelehrung anzeigen",
"modal_description": "So informieren wir über das Widerrufsrecht. Der volle Text gilt für deinen Kauf.", "modal_description": "So informieren wir über das Widerrufsrecht. Der volle Text gilt für deinen Kauf.",
"modal_loading": "Widerrufsbelehrung wird geladen…", "modal_loading": "Widerrufsbelehrung wird geladen…",
@@ -770,7 +774,7 @@
"timeline": [ "timeline": [
{ {
"title": "Event vorbereiten", "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": [ "tips": [
"Testevent anlegen, um Upload-Flow vorab zu prüfen", "Testevent anlegen, um Upload-Flow vorab zu prüfen",
"Trauzeug:innen oder Kolleg:innen als Co-Hosts einladen" "Trauzeug:innen oder Kolleg:innen als Co-Hosts einladen"

View File

@@ -82,14 +82,14 @@
"packages": { "packages": {
"title": "Our Packages", "title": "Our Packages",
"features": "Features", "features": "Features",
"subscription_annual": "Annual Subscription", "subscription_annual": "Event kontingent",
"auto_renew": "auto-renew", "auto_renew": "auto-renew",
"cancel_anytime": "cancel anytime", "cancel_anytime": "cancel anytime",
"trial_start": "Free Trial for :days days", "trial_start": "Free Trial for :days days",
"reseller_benefits": "Benefits for Resellers", "reseller_benefits": "Benefits for Partner / Agencies",
"unlimited_events": "Unlimited Events", "unlimited_events": "Unlimited Events",
"priority_support": "Priority Support", "priority_support": "Priority Support",
"cancel_link": "Cancel Subscription: :link", "cancel_link": "Manage package: :link",
"hero_title": "Discover our flexible Packages", "hero_title": "Discover our flexible Packages",
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure, and scalable.", "hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure, and scalable.",
"hero_secondary": "Experience the full guest flow in our live demo no login, no install.", "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": "Discover Packages",
"cta_explore_highlight": "Explore top packages", "cta_explore_highlight": "Explore top packages",
"tab_endcustomer": "End Customers", "tab_endcustomer": "End Customers",
"tab_reseller": "Resellers & Agencies", "tab_reseller": "Partner / Agency",
"section_endcustomer": "Packages for End Customers (One-time purchase per event)", "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", "free": "Free",
"one_time": "One-time purchase", "one_time": "One-time purchase",
"subscription": "Subscription", "subscription": "Event kontingent",
"year": "Year", "year": "Year",
"max_photos": "Photos", "max_photos": "Photos",
"max_guests": "Guests", "max_guests": "Guests",
"gallery_days": "Gallery Days", "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", "buy_now": "Buy Now",
"subscribe_now": "Subscribe Now", "subscribe_now": "Buy event kontingent",
"register_buy": "Register and Buy", "register_buy": "Register and Buy",
"register_subscribe": "Register and Subscribe", "register_subscribe": "Register and buy",
"faq_title": "Frequently Asked Questions about Packages", "faq_title": "Frequently Asked Questions about Packages",
"faq_lead": "Quick answers to the essentials check “How it works” for the full deep dive.", "faq_lead": "Quick answers to the essentials check “How it works” for the full deep dive.",
"faq_q1": "What is a Package?", "faq_q1": "What is a Package?",
@@ -140,7 +143,7 @@
"feature_limited_sharing": "Limited Sharing", "feature_limited_sharing": "Limited Sharing",
"feature_no_branding": "No Branding", "feature_no_branding": "No Branding",
"feature_0": "Basic Feature", "feature_0": "Basic Feature",
"feature_reseller_dashboard": "Reseller Dashboard", "feature_reseller_dashboard": "Partner dashboard",
"feature_custom_branding": "Custom Branding", "feature_custom_branding": "Custom Branding",
"feature_advanced_reporting": "Advanced Reporting", "feature_advanced_reporting": "Advanced Reporting",
"badge_most_popular": "Most Popular", "badge_most_popular": "Most Popular",
@@ -148,6 +151,7 @@
"badge_starter": "Perfect Starter", "badge_starter": "Perfect Starter",
"billing_per_event": "per event", "billing_per_event": "per event",
"billing_per_year": "per year", "billing_per_year": "per year",
"billing_per_kontingent": "per bundle",
"more_features": "+{{count}} more features", "more_features": "+{{count}} more features",
"feature_overview": "Feature overview", "feature_overview": "Feature overview",
"order_hint": "Launch instantly secure Paddle checkout, no hidden fees.", "order_hint": "Launch instantly secure Paddle checkout, no hidden fees.",
@@ -159,7 +163,7 @@
"tasks": "Challenges", "tasks": "Challenges",
"gallery": "Gallery", "gallery": "Gallery",
"branding": "Branding", "branding": "Branding",
"events_per_year": "Events per year" "events_per_year": "Events included"
}, },
"more_details_tab": "More Details", "more_details_tab": "More Details",
"quick_facts": "Quick Facts", "quick_facts": "Quick Facts",
@@ -171,7 +175,7 @@
"limits_label": "Limits & Capacity", "limits_label": "Limits & Capacity",
"limits_label_hint": "Understand the exact limits for planning and approvals.", "limits_label_hint": "Understand the exact limits for planning and approvals.",
"for_endcustomers": "For End Customers", "for_endcustomers": "For End Customers",
"for_resellers": "For Resellers", "for_resellers": "For Partner / Agencies",
"view_details": "View details", "view_details": "View details",
"details_show": "Show Details", "details_show": "Show Details",
"comparison_title": "Compare Packages", "comparison_title": "Compare Packages",
@@ -190,10 +194,10 @@
"not_available": "Not available", "not_available": "Not available",
"standard_support": "Standard support", "standard_support": "Standard support",
"max_tenants": "Max. Tenants", "max_tenants": "Max. Tenants",
"max_events": "Max. Events/Year", "max_events": "Events included",
"faq_free": "What is the Free Package?", "faq_free": "What is the Free Package?",
"faq_upgrade": "Can I upgrade?", "faq_upgrade": "Can I upgrade?",
"faq_reseller": "What for Resellers?", "faq_reseller": "What for Partner / Agencies?",
"faq_payment": "Payment secure?", "faq_payment": "Payment secure?",
"testimonials": { "testimonials": {
"anna": "Fotospiel made our wedding perfect! Guests could easily share photos, and the gallery was a hit.", "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.", "purchase_complete_desc": "Log in to continue.",
"login": "Log In", "login": "Log In",
"no_account": "No Account? Register", "no_account": "No Account? Register",
"manage_subscription": "Manage Subscription", "manage_subscription": "Manage kontingent",
"stripe_dashboard": "Stripe Dashboard", "stripe_dashboard": "Stripe Dashboard",
"trial_activated": "Trial activated for 14 days!" "trial_activated": "Trial activated for 14 days!"
}, },
@@ -481,7 +485,7 @@
"summary_title": "Your order", "summary_title": "Your order",
"package_label": "Selected package", "package_label": "Selected package",
"billing_type_one_time": "One-time purchase (per event)", "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:", "legal_links_intro": "Details on the withdrawal policy:",
"link_terms": "Terms & Conditions", "link_terms": "Terms & Conditions",
"link_privacy": "Privacy Policy", "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_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_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.", "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", "open_withdrawal": "View withdrawal policy",
"modal_description": "Below is the current withdrawal policy for your purchase.", "modal_description": "Below is the current withdrawal policy for your purchase.",
"modal_loading": "Loading withdrawal policy…", "modal_loading": "Loading withdrawal policy…",

View File

@@ -433,6 +433,8 @@ export type TenantPackageSummary = {
id: number; id: number;
package_id: number; package_id: number;
package_name: string; package_name: string;
package_type: string | null;
included_package_slug: string | null;
active: boolean; active: boolean;
used_events: number; used_events: number;
remaining_events: number | null; remaining_events: number | null;
@@ -743,6 +745,7 @@ type EventSavePayload = {
status?: 'draft' | 'published' | 'archived'; status?: 'draft' | 'published' | 'archived';
is_active?: boolean; is_active?: boolean;
package_id?: number; package_id?: number;
service_package_slug?: string;
accepted_waiver?: boolean; accepted_waiver?: boolean;
settings?: Record<string, unknown> & { settings?: Record<string, unknown> & {
live_show?: LiveShowSettings; live_show?: LiveShowSettings;
@@ -1008,6 +1011,18 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
id: Number(pkg.id ?? 0), id: Number(pkg.id ?? 0),
package_id: Number(pkg.package_id ?? packageData.id ?? 0), package_id: Number(pkg.package_id ?? packageData.id ?? 0),
package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'), 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), active: Boolean(pkg.active ?? false),
used_events: Number(pkg.used_events ?? 0), used_events: Number(pkg.used_events ?? 0),
remaining_events: pkg.remaining_events !== undefined ? Number(pkg.remaining_events) : null, remaining_events: pkg.remaining_events !== undefined ? Number(pkg.remaining_events) : null,
@@ -2099,11 +2114,19 @@ export async function submitTenantFeedback(payload: {
export type Package = { export type Package = {
id: number; id: number;
name: string; name: string;
slug?: string;
type?: 'endcustomer' | 'reseller';
price: number; price: number;
max_photos: number | null; max_photos: number | null;
max_guests: number | null; max_guests: number | null;
gallery_days: number | null; gallery_days: number | null;
features: Record<string, boolean>; 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<string, boolean> | null;
}; };
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> { export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {

View File

@@ -2,8 +2,8 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
const CREDENTIALS: Record<string, { login: string; password: string }> = { const CREDENTIALS: Record<string, { login: string; password: string }> = {
'cust-standard-empty': { login: 'standard-empty@demo.fotospiel', password: 'Demo1234!' }, 'cust-standard-empty': { login: 'standard-empty@demo.fotospiel', password: 'Demo1234!' },
'cust-starter-wedding': { login: 'starter-wedding@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-active': { login: 'partner-active@demo.fotospiel', password: 'Demo1234!' },
'reseller-s-full': { login: 'reseller-full@demo.fotospiel', password: 'Demo1234!' }, 'reseller-s-full': { login: 'partner-full@demo.fotospiel', password: 'Demo1234!' },
}; };
async function loginAs(key: string): Promise<void> { async function loginAs(key: string): Promise<void> {

View File

@@ -74,8 +74,8 @@
}, },
"errors": { "errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.", "eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.",
"eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben.", "eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben im Kontingent.",
"photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.", "photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.",
"goToBilling": "Zur Paketverwaltung" "goToBilling": "Zur Paketverwaltung"
}, },
@@ -174,7 +174,7 @@
"plans": { "plans": {
"title": "Pakete im Überblick", "title": "Pakete im Überblick",
"subtitle": "Wähle das passende Kontingent", "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": { "starter": {
"title": "Starter", "title": "Starter",
"badge": "Für ein Event", "badge": "Für ein Event",
@@ -191,23 +191,23 @@
"p3": "Support bei Live-Events" "p3": "Support bei Live-Events"
}, },
"reseller": { "reseller": {
"title": "Reseller S", "title": "Partner Start",
"badge": "Für Dienstleister", "badge": "Für Agenturen",
"highlight": "Mehrere Events parallel verwalten", "highlight": "Mehrere Events parallel verwalten",
"p1": "Bis zu 5 Events pro Paket", "p1": "Bis zu 5 Events pro Kontingent",
"p2": "Aufgaben-Sammlungen und Vorlagen", "p2": "Aufgaben-Sammlungen und Vorlagen",
"p3": "Teamrollen & Rechteverwaltung" "p3": "Teamrollen & Rechteverwaltung"
} }
}, },
"audience": { "audience": {
"title": "Für wen?", "title": "Für wen?",
"subtitle": "Endkunden & Reseller im Blick", "subtitle": "Endkunden & Partner im Blick",
"endcustomers": { "endcustomers": {
"title": "Endkund:innen", "title": "Endkund:innen",
"description": "Schnell einrichten, mobil moderieren und nach dem Event die Galerie teilen." "description": "Schnell einrichten, mobil moderieren und nach dem Event die Galerie teilen."
}, },
"resellers": { "resellers": {
"title": "Reseller & Agenturen", "title": "Partner / Agenturen",
"description": "Mehrere Events im Blick behalten, Kontingente überwachen und Vorlagen nutzen." "description": "Mehrere Events im Blick behalten, Kontingente überwachen und Vorlagen nutzen."
}, },
"cta": "Wenige Klicks bis zum Start" "cta": "Wenige Klicks bis zum Start"

View File

@@ -32,8 +32,8 @@
"publishedHint": "{{count}} veröffentlicht", "publishedHint": "{{count}} veröffentlicht",
"newPhotos": "Neue Fotos (7 Tage)", "newPhotos": "Neue Fotos (7 Tage)",
"taskProgress": "Task-Fortschritt", "taskProgress": "Task-Fortschritt",
"credits": "Event-Slots", "credits": "Event-Kontingent",
"lowCredits": "Mehr Slots buchen empfohlen" "lowCredits": "Mehr Kontingent buchen empfohlen"
} }
}, },
"liveNow": { "liveNow": {
@@ -238,8 +238,8 @@
"publishedHint": "{{count}} veröffentlicht", "publishedHint": "{{count}} veröffentlicht",
"newPhotos": "Neue Fotos (7 Tage)", "newPhotos": "Neue Fotos (7 Tage)",
"taskProgress": "Task-Fortschritt", "taskProgress": "Task-Fortschritt",
"credits": "Event-Slots", "credits": "Event-Kontingent",
"lowCredits": "Mehr Slots buchen empfohlen" "lowCredits": "Mehr Kontingent buchen empfohlen"
} }
}, },
"quickActions": { "quickActions": {

View File

@@ -90,7 +90,7 @@
}, },
"warnings": { "warnings": {
"noEvents": "Event-Kontingent aufgebraucht. Bitte Paket upgraden oder erneuern.", "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.", "expiresSoon": "Paket läuft am {{date}} ab.",
"expired": "Paket ist abgelaufen." "expired": "Paket ist abgelaufen."
} }
@@ -108,7 +108,7 @@
"expires": "Läuft ab", "expires": "Läuft ab",
"warnings": { "warnings": {
"noEvents": "Event-Kontingent aufgebraucht.", "noEvents": "Event-Kontingent aufgebraucht.",
"lowEvents": "Nur noch {{remaining}} Events verbleiben.", "lowEvents": "Nur noch {{remaining}} Events im Kontingent verbleiben.",
"expiresSoon": "Läuft am {{date}} ab.", "expiresSoon": "Läuft am {{date}} ab.",
"expired": "Paket ist abgelaufen." "expired": "Paket ist abgelaufen."
} }
@@ -1558,12 +1558,12 @@
"title": "Benachrichtigungsübersicht", "title": "Benachrichtigungsübersicht",
"channel": "E-Mail Kanal", "channel": "E-Mail Kanal",
"channelCopy": "Alle Warnungen werden per E-Mail versendet.", "channelCopy": "Alle Warnungen werden per E-Mail versendet.",
"credits": "Credits", "credits": "Event-Kontingent",
"threshold": "Warnung bei {{count}} verbleibenden Slots" "threshold": "Warnung bei {{count}} verbleibenden Events"
}, },
"meta": { "meta": {
"creditLast": "Letzte Slot-Warnung: {{date}}", "creditLast": "Letzte Kontingent-Warnung: {{date}}",
"creditNever": "Noch keine Slot-Warnung versendet." "creditNever": "Noch keine Kontingent-Warnung versendet."
}, },
"items": { "items": {
"photoThresholds": { "photoThresholds": {
@@ -1592,7 +1592,7 @@
}, },
"eventThresholds": { "eventThresholds": {
"label": "Warnung bei Event-Kontingent", "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": { "eventLimits": {
"label": "Sperre bei Event-Kontingent", "label": "Sperre bei Event-Kontingent",
@@ -2192,7 +2192,7 @@
"featuresTitle": "Enthaltene Features", "featuresTitle": "Enthaltene Features",
"feature": { "feature": {
"priority_support": "Priority Support", "priority_support": "Priority Support",
"reseller_dashboard": "Reseller-Dashboard", "reseller_dashboard": "Partner-Dashboard",
"custom_domain": "Eigene Domain", "custom_domain": "Eigene Domain",
"custom_branding": "Benutzerdefiniertes Branding", "custom_branding": "Benutzerdefiniertes Branding",
"custom_tasks": "Individuelle Aufgaben", "custom_tasks": "Individuelle Aufgaben",
@@ -2907,7 +2907,7 @@
"max_guests": "Gäste", "max_guests": "Gäste",
"max_tasks": "Aufgaben", "max_tasks": "Aufgaben",
"gallery_days": "Galerietage", "gallery_days": "Galerietage",
"max_events_per_year": "Events pro Jahr" "max_events_per_year": "Event-Kontingent"
}, },
"mobileEvents": { "mobileEvents": {
"edit": "Event bearbeiten" "edit": "Event bearbeiten"
@@ -3064,6 +3064,30 @@
"shop": { "shop": {
"title": "Paket upgraden", "title": "Paket upgraden",
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.", "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", "recommendationTitle": "Empfohlen für dich",
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.", "recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
"compare": { "compare": {

View File

@@ -41,7 +41,7 @@
"ctaList": { "ctaList": {
"choosePackage": { "choosePackage": {
"label": "Dein Eventpaket auswählen", "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" "button": "Weiter zu Paketen"
}, },
"createEvent": { "createEvent": {
@@ -61,7 +61,7 @@
"steps": { "steps": {
"package": { "package": {
"title": "Paket sichern", "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": { "invite": {
"title": "Team einladen", "title": "Team einladen",
@@ -77,10 +77,10 @@
"layout": { "layout": {
"eyebrow": "Schritt 2", "eyebrow": "Schritt 2",
"title": "Wähle dein Eventpaket", "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": { "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." "description": "Sichere dir Kapazität für dein nächstes Event. Du kannst jederzeit upgraden bezahle nur, was du brauchst."
}, },
"state": { "state": {
@@ -92,7 +92,7 @@
}, },
"card": { "card": {
"subscription": "Abo", "subscription": "Abo",
"creditPack": "Event-Slot-Paket", "creditPack": "Event-Kontingent",
"description": "Sofort einsatzbereit für dein nächstes Event.", "description": "Sofort einsatzbereit für dein nächstes Event.",
"descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive perfekt für lebendige Reportagen.", "descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive perfekt für lebendige Reportagen.",
"active": "Aktives Paket", "active": "Aktives Paket",
@@ -151,7 +151,7 @@
}, },
"details": { "details": {
"subscription": "Abo", "subscription": "Abo",
"creditPack": "Event-Slot-Paket", "creditPack": "Event-Kontingent",
"photos": "Bis zu {{count}} Fotos", "photos": "Bis zu {{count}} Fotos",
"galleryDays": "Galerie {{count}} Tage", "galleryDays": "Galerie {{count}} Tage",
"guests": "{{count}} Gäste", "guests": "{{count}} Gäste",
@@ -188,7 +188,7 @@
"activate": "Gratis-Paket aktivieren", "activate": "Gratis-Paket aktivieren",
"progress": "Aktivierung läuft …", "progress": "Aktivierung läuft …",
"successTitle": "Gratis-Paket aktiviert", "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", "failureTitle": "Aktivierung fehlgeschlagen",
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
}, },
@@ -205,12 +205,12 @@
"nextSteps": [ "nextSteps": [
"Optional: Abrechnung über Paddle im Billing-Bereich abschließen.", "Optional: Abrechnung über Paddle im Billing-Bereich abschließen.",
"Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.", "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": { "cta": {
"billing": { "billing": {
"label": "Abrechnung starten", "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" "button": "Zu Billing & Zahlung"
}, },
"setup": { "setup": {

View File

@@ -74,8 +74,8 @@
}, },
"errors": { "errors": {
"generic": "Something went wrong. Please try again.", "generic": "Something went wrong. Please try again.",
"eventLimit": "Your current package has no remaining event slots.", "eventLimit": "Your current package has no remaining event kontingent.",
"eventLimitDetails": "{used} of {limit} events used. {remaining} remaining.", "eventLimitDetails": "{used} of {limit} events used. {remaining} remaining in the kontingent.",
"photoLimit": "This event reached its photo upload limit.", "photoLimit": "This event reached its photo upload limit.",
"goToBilling": "Manage subscription" "goToBilling": "Manage subscription"
}, },
@@ -174,7 +174,7 @@
"plans": { "plans": {
"title": "Packages at a glance", "title": "Packages at a glance",
"subtitle": "Choose the right quota", "subtitle": "Choose the right quota",
"hint": "Starter, Standard or Reseller all include moderation & invites.", "hint": "Starter, Standard or Partner all include moderation & invites.",
"starter": { "starter": {
"title": "Starter", "title": "Starter",
"badge": "For one event", "badge": "For one event",
@@ -191,24 +191,24 @@
"p3": "Support on live days" "p3": "Support on live days"
}, },
"reseller": { "reseller": {
"title": "Reseller S", "title": "Partner Start",
"badge": "For pros", "badge": "For agencies",
"highlight": "Manage multiple events", "highlight": "Manage multiple events",
"p1": "Up to 5 events per package", "p1": "Up to 5 events per kontingent",
"p2": "Task collections and templates", "p2": "Task collections and templates",
"p3": "Team roles & permissions" "p3": "Team roles & permissions"
} }
}, },
"audience": { "audience": {
"title": "Who is it for?", "title": "Who is it for?",
"subtitle": "Built for hosts and resellers", "subtitle": "Built for hosts and partners",
"endcustomers": { "endcustomers": {
"title": "Event hosts", "title": "Event hosts",
"description": "Set up fast, moderate on mobile and share the gallery afterwards." "description": "Set up fast, moderate on mobile and share the gallery afterwards."
}, },
"resellers": { "resellers": {
"title": "Resellers & agencies", "title": "Partner / Agencies",
"description": "Track multiple events, monitor quotas and reuse templates." "description": "Track multiple events, monitor kontingent and reuse templates."
}, },
"cta": "Just a few clicks to go live" "cta": "Just a few clicks to go live"
}, },

View File

@@ -32,8 +32,8 @@
"publishedHint": "{{count}} published", "publishedHint": "{{count}} published",
"newPhotos": "New photos (7 days)", "newPhotos": "New photos (7 days)",
"taskProgress": "Task progress", "taskProgress": "Task progress",
"credits": "Event slots", "credits": "Event kontingent",
"lowCredits": "Add slots soon" "lowCredits": "Add kontingent soon"
} }
}, },
"liveNow": { "liveNow": {
@@ -238,8 +238,8 @@
"publishedHint": "{{count}} published", "publishedHint": "{{count}} published",
"newPhotos": "New photos (7 days)", "newPhotos": "New photos (7 days)",
"taskProgress": "Task progress", "taskProgress": "Task progress",
"credits": "Event slots", "credits": "Event kontingent",
"lowCredits": "Add slots soon" "lowCredits": "Add kontingent soon"
} }
}, },
"quickActions": { "quickActions": {

View File

@@ -90,7 +90,7 @@
}, },
"warnings": { "warnings": {
"noEvents": "Event allowance exhausted. Please upgrade or renew your package.", "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}}.", "expiresSoon": "Package expires on {{date}}.",
"expired": "Package has expired." "expired": "Package has expired."
} }
@@ -108,7 +108,7 @@
"expires": "Expires", "expires": "Expires",
"warnings": { "warnings": {
"noEvents": "Event allowance exhausted.", "noEvents": "Event allowance exhausted.",
"lowEvents": "Only {{remaining}} events left.", "lowEvents": "Only {{remaining}} events remaining in the kontingent.",
"expiresSoon": "Expires on {{date}}.", "expiresSoon": "Expires on {{date}}.",
"expired": "Package has expired." "expired": "Package has expired."
} }
@@ -1556,12 +1556,12 @@
"title": "Notification overview", "title": "Notification overview",
"channel": "Email channel", "channel": "Email channel",
"channelCopy": "All warnings are delivered via email.", "channelCopy": "All warnings are delivered via email.",
"credits": "Credits", "credits": "Event kontingent",
"threshold": "Warning at {{count}} remaining slots" "threshold": "Warning at {{count}} remaining events"
}, },
"meta": { "meta": {
"creditLast": "Last slot warning: {{date}}", "creditLast": "Last kontingent warning: {{date}}",
"creditNever": "No slot warning sent yet." "creditNever": "No kontingent warning sent yet."
}, },
"items": { "items": {
"photoThresholds": { "photoThresholds": {
@@ -1590,7 +1590,7 @@
}, },
"eventThresholds": { "eventThresholds": {
"label": "Event quota warning", "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": { "eventLimits": {
"label": "Event quota exhausted", "label": "Event quota exhausted",
@@ -2196,7 +2196,7 @@
"featuresTitle": "Included features", "featuresTitle": "Included features",
"feature": { "feature": {
"priority_support": "Priority support", "priority_support": "Priority support",
"reseller_dashboard": "Reseller dashboard", "reseller_dashboard": "Partner dashboard",
"custom_domain": "Custom domain", "custom_domain": "Custom domain",
"custom_branding": "Custom branding", "custom_branding": "Custom branding",
"custom_tasks": "Custom tasks", "custom_tasks": "Custom tasks",
@@ -2911,7 +2911,7 @@
"max_guests": "Guests", "max_guests": "Guests",
"max_tasks": "Tasks", "max_tasks": "Tasks",
"gallery_days": "Gallery days", "gallery_days": "Gallery days",
"max_events_per_year": "Events per year" "max_events_per_year": "Event kontingent"
}, },
"mobileEvents": { "mobileEvents": {
"edit": "Edit event" "edit": "Edit event"
@@ -3068,6 +3068,30 @@
"shop": { "shop": {
"title": "Upgrade Package", "title": "Upgrade Package",
"subtitle": "Choose a package to unlock more features and limits.", "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", "recommendationTitle": "Recommended for you",
"recommendationBody": "The highlighted package includes the feature you requested.", "recommendationBody": "The highlighted package includes the feature you requested.",
"compare": { "compare": {

View File

@@ -41,7 +41,7 @@
"ctaList": { "ctaList": {
"choosePackage": { "choosePackage": {
"label": "Choose your package", "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" "button": "Continue to packages"
}, },
"createEvent": { "createEvent": {
@@ -61,7 +61,7 @@
"steps": { "steps": {
"package": { "package": {
"title": "Secure your 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": { "invite": {
"title": "Invite your co-hosts", "title": "Invite your co-hosts",
@@ -77,10 +77,10 @@
"layout": { "layout": {
"eyebrow": "Step 2", "eyebrow": "Step 2",
"title": "Choose your package", "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": { "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." "description": "Secure capacity for your next event. Upgrade at any time only pay for what you need."
}, },
"state": { "state": {
@@ -92,7 +92,7 @@
}, },
"card": { "card": {
"subscription": "Subscription", "subscription": "Subscription",
"creditPack": "Event slot pack", "creditPack": "Event kontingent",
"description": "Ready for your next event right away.", "description": "Ready for your next event right away.",
"descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.", "descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.",
"active": "Active package", "active": "Active package",
@@ -151,7 +151,7 @@
}, },
"details": { "details": {
"subscription": "Subscription", "subscription": "Subscription",
"creditPack": "Event slot pack", "creditPack": "Event kontingent",
"photos": "Up to {{count}} photos", "photos": "Up to {{count}} photos",
"galleryDays": "{{count}} gallery days", "galleryDays": "{{count}} gallery days",
"guests": "{{count}} guests", "guests": "{{count}} guests",
@@ -188,7 +188,7 @@
"activate": "Activate free package", "activate": "Activate free package",
"progress": "Activating …", "progress": "Activating …",
"successTitle": "Free package activated", "successTitle": "Free package activated",
"successDescription": "Event slots added. Continue with the setup.", "successDescription": "Event kontingent added. Continue with the setup.",
"failureTitle": "Activation failed", "failureTitle": "Activation failed",
"errorMessage": "The free package could not be activated." "errorMessage": "The free package could not be activated."
}, },
@@ -205,12 +205,12 @@
"nextSteps": [ "nextSteps": [
"Optional: finish billing via Paddle inside the billing area.", "Optional: finish billing via Paddle inside the billing area.",
"Complete the event setup and configure tasks, team, and gallery.", "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": { "cta": {
"billing": { "billing": {
"label": "Start 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" "button": "Go to billing"
}, },
"setup": { "setup": {

View File

@@ -58,6 +58,12 @@ export default function MobileBillingPage() {
const invoicesRef = React.useRef<HTMLDivElement | null>(null); const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de'; const supportEmail = 'support@fotospiel.de';
const back = useBackNavigation(adminPath('/mobile/profile')); 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 () => { const load = React.useCallback(async () => {
setLoading(true); setLoading(true);
@@ -281,7 +287,7 @@ export default function MobileBillingPage() {
<XStack space="$2"> <XStack space="$2">
<CTAButton <CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')} label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))} onPress={() => navigate(shopLink)}
fullWidth={false} fullWidth={false}
/> />
<CTAButton <CTAButton
@@ -316,7 +322,7 @@ export default function MobileBillingPage() {
window.open(checkoutActionUrl, '_blank', 'noopener'); window.open(checkoutActionUrl, '_blank', 'noopener');
return; return;
} }
navigate(adminPath('/mobile/billing/shop')); navigate(shopLink);
}} }}
fullWidth={false} fullWidth={false}
/> />
@@ -385,7 +391,7 @@ export default function MobileBillingPage() {
pkg={activePackage} pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')} label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
isActive isActive
onOpenShop={() => navigate(adminPath('/mobile/billing/shop'))} onOpenShop={() => navigate(shopLink)}
/> />
) : null} ) : null}
{packages {packages
@@ -501,6 +507,15 @@ function PackageCard({
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null; const limits = (pkg.package_limits ?? null) as Record<string, unknown> | 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 limitMaxEvents = typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null;
const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0; const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0;
const remainingText = const remainingText =
@@ -520,7 +535,7 @@ function PackageCard({
const limitEntries = getPackageLimitEntries(limits, t, { const limitEntries = getPackageLimitEntries(limits, t, {
remainingEvents: pkg.remaining_events ?? null, remainingEvents: pkg.remaining_events ?? null,
usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null, usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null,
}); }, { packageType: pkg.package_type });
const featureKeys = collectPackageFeatures(pkg); const featureKeys = collectPackageFeatures(pkg);
const eventUsageText = formatEventUsage( const eventUsageText = formatEventUsage(
typeof pkg.used_events === 'number' ? pkg.used_events : null, typeof pkg.used_events === 'number' ? pkg.used_events : null,
@@ -550,8 +565,9 @@ function PackageCard({
{pkg.price !== null && pkg.price !== undefined ? ( {pkg.price !== null && pkg.price !== undefined ? (
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge> <PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
) : null} ) : null}
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))} {isPartnerPackage && includedTierLabel ? <PillBadge tone="muted">{includedTierLabel}</PillBadge> : null}
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))} {!isPartnerPackage ? renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding')) : null}
{!isPartnerPackage ? renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark')) : null}
</XStack> </XStack>
{eventUsageText ? ( {eventUsageText ? (
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>

View File

@@ -354,6 +354,7 @@ export default function MobileDashboardPage() {
navigate(adminPath('/mobile/events/new')); navigate(adminPath('/mobile/events/new'));
}} }}
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')} packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
packageType={activePackage.package_type ?? null}
remainingEvents={remainingEvents} remainingEvents={remainingEvents}
purchasedAt={activePackage.purchased_at} purchasedAt={activePackage.purchased_at}
expiresAt={activePackage.expires_at} expiresAt={activePackage.expires_at}
@@ -497,6 +498,7 @@ function PackageSummarySheet({
onClose, onClose,
onContinue, onContinue,
packageName, packageName,
packageType,
remainingEvents, remainingEvents,
purchasedAt, purchasedAt,
expiresAt, expiresAt,
@@ -508,6 +510,7 @@ function PackageSummarySheet({
onClose: () => void; onClose: () => void;
onContinue: () => void; onContinue: () => void;
packageName: string; packageName: string;
packageType: string | null;
remainingEvents: number | null | undefined; remainingEvents: number | null | undefined;
purchasedAt: string | null | undefined; purchasedAt: string | null | undefined;
expiresAt: string | null | undefined; expiresAt: string | null | undefined;
@@ -523,8 +526,9 @@ function PackageSummarySheet({
package_limits: limits, package_limits: limits,
branding_allowed: (limits as any)?.branding_allowed ?? null, branding_allowed: (limits as any)?.branding_allowed ?? null,
watermark_allowed: (limits as any)?.watermark_allowed ?? null, watermark_allowed: (limits as any)?.watermark_allowed ?? null,
package_type: packageType,
} as any); } as any);
const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents }); const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents }, { packageType });
const hasFeatures = resolvedFeatures.length > 0; const hasFeatures = resolvedFeatures.length > 0;
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown'); const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');

View File

@@ -9,7 +9,19 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet'; 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 { resolveEventSlugAfterUpdate } from './eventFormNavigation';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
@@ -30,6 +42,7 @@ type FormState = {
autoApproveUploads: boolean; autoApproveUploads: boolean;
tasksEnabled: boolean; tasksEnabled: boolean;
packageId: number | null; packageId: number | null;
servicePackageSlug: string | null;
}; };
export default function MobileEventFormPage() { export default function MobileEventFormPage() {
@@ -52,11 +65,14 @@ export default function MobileEventFormPage() {
autoApproveUploads: true, autoApproveUploads: true,
tasksEnabled: true, tasksEnabled: true,
packageId: null, packageId: null,
servicePackageSlug: null,
}); });
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]); const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false); const [typesLoading, setTypesLoading] = React.useState(false);
const [packages, setPackages] = React.useState<Package[]>([]); const [packages, setPackages] = React.useState<Package[]>([]);
const [packagesLoading, setPackagesLoading] = React.useState(false); const [packagesLoading, setPackagesLoading] = React.useState(false);
const [kontingentOptions, setKontingentOptions] = React.useState<Array<{ slug: string; remaining: number }>>([]);
const [kontingentLoading, setKontingentLoading] = React.useState(false);
const [loading, setLoading] = React.useState(isEdit); const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [consentOpen, setConsentOpen] = 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.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
(data.engagement_mode as string | undefined) !== 'photo_only', (data.engagement_mode as string | undefined) !== 'photo_only',
packageId: null, packageId: null,
servicePackageSlug: null,
}); });
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -139,6 +156,75 @@ export default function MobileEventFormPage() {
})(); })();
}, [isSuperAdmin, isEdit]); }, [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<string, number>();
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() { async function handleSubmit() {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -165,6 +251,7 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined, event_date: form.date || undefined,
status: form.published ? 'published' : 'draft', status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined, package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
service_package_slug: form.servicePackageSlug ?? undefined,
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -188,6 +275,7 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined, event_date: form.date || undefined,
status: form.published ? 'published' : 'draft', status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined, package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
service_package_slug: form.servicePackageSlug ?? undefined,
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -283,6 +371,34 @@ export default function MobileEventFormPage() {
</MobileField> </MobileField>
) : null} ) : null}
{!isEdit && (kontingentLoading || kontingentOptions.length > 0) ? (
<MobileField label={t('eventForm.fields.servicePackage.label', 'Event-Level (Event-Kontingent)')}>
{kontingentLoading ? (
<Text fontSize="$sm" color={muted}>
{t('eventForm.fields.servicePackage.loading', 'Loading Event-Kontingente…')}
</Text>
) : (
<MobileSelect
value={form.servicePackageSlug ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, servicePackageSlug: String(e.target.value) }))}
>
<option value="">{t('eventForm.fields.servicePackage.placeholder', 'Select tier')}</option>
{kontingentOptions.map((opt) => (
<option key={opt.slug} value={opt.slug}>
{resolveServiceTierLabel(opt.slug)} · {opt.remaining} {t('eventForm.fields.servicePackage.events', 'Events')}
</option>
))}
</MobileSelect>
)}
<Text fontSize="$xs" color={muted}>
{t(
'eventForm.fields.servicePackage.help',
'Wählt das Event-Level. Pro Event wird 1 aus dem passenden Event-Kontingent verbraucht.',
)}
</Text>
</MobileField>
) : null}
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}> <MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<NativeDateTimeInput <NativeDateTimeInput

View File

@@ -30,22 +30,39 @@ export default function MobilePackageShopPage() {
// Extract recommended feature from URL // Extract recommended feature from URL
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
const recommendedFeature = searchParams.get('feature'); const recommendedFeature = searchParams.get('feature');
const forcedCatalogType = searchParams.get('type');
const { data: catalog, isLoading: loadingCatalog } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
const { data: inventory, isLoading: loadingInventory } = useQuery({ const { data: inventory, isLoading: loadingInventory } = useQuery({
queryKey: ['tenant-packages-overview'], queryKey: ['tenant-packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }), 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; const isLoading = loadingCatalog || loadingInventory;
if (isLoading) { if (isLoading) {
return ( return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile"> <MobileShell
title={
catalogType === 'reseller'
? t('shop.partner.title', 'Event-Kontingent kaufen')
: t('shop.title', 'Upgrade Package')
}
onBack={() => navigate(-1)}
activeTab="profile"
>
<YStack space="$3"> <YStack space="$3">
<SkeletonCard height={150} /> <SkeletonCard height={150} />
<SkeletonCard height={150} /> <SkeletonCard height={150} />
@@ -65,7 +82,10 @@ export default function MobilePackageShopPage() {
const activePackageId = inventory?.activePackage?.package_id ?? null; const activePackageId = inventory?.activePackage?.package_id ?? null;
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? 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 // Merge and sort packages
const sortedPackages = [...(catalog || [])].sort((a, b) => { const sortedPackages = [...(catalog || [])].sort((a, b) => {
@@ -78,10 +98,14 @@ export default function MobilePackageShopPage() {
}); });
const packageEntries = sortedPackages.map((pkg) => { const packageEntries = sortedPackages.map((pkg) => {
const owned = inventory?.packages?.find((entry) => entry.package_id === pkg.id); const ownedEntries = (inventory?.packages ?? []).filter((entry) => entry.package_id === pkg.id && entry.active);
const isActive = inventory?.activePackage?.package_id === pkg.id; 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 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 { return {
pkg, pkg,
@@ -94,9 +118,13 @@ export default function MobilePackageShopPage() {
}); });
return ( return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile"> <MobileShell
title={catalogType === 'reseller' ? t('shop.partner.title', 'Event-Kontingent kaufen') : t('shop.title', 'Upgrade Package')}
onBack={() => navigate(-1)}
activeTab="profile"
>
<YStack space="$4"> <YStack space="$4">
{recommendedFeature && ( {catalogType !== 'reseller' && recommendedFeature && (
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3"> <MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
<XStack space="$2" alignItems="center"> <XStack space="$2" alignItems="center">
<Sparkles size={16} color={primary} /> <Sparkles size={16} color={primary} />
@@ -112,7 +140,9 @@ export default function MobilePackageShopPage() {
<YStack paddingHorizontal="$2"> <YStack paddingHorizontal="$2">
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{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.')}
</Text> </Text>
</YStack> </YStack>
@@ -140,6 +170,7 @@ export default function MobilePackageShopPage() {
<PackageShopCompareView <PackageShopCompareView
entries={packageEntries} entries={packageEntries}
onSelect={(pkg) => setSelectedPackage(pkg)} onSelect={(pkg) => setSelectedPackage(pkg)}
catalogType={catalogType}
/> />
) : ( ) : (
packageEntries.map((entry) => ( packageEntries.map((entry) => (
@@ -151,6 +182,7 @@ export default function MobilePackageShopPage() {
isRecommended={entry.isRecommended} isRecommended={entry.isRecommended}
isUpgrade={entry.isUpgrade} isUpgrade={entry.isUpgrade}
isDowngrade={entry.isDowngrade} isDowngrade={entry.isDowngrade}
catalogType={catalogType}
onSelect={() => setSelectedPackage(entry.pkg)} onSelect={() => setSelectedPackage(entry.pkg)}
/> />
)) ))
@@ -168,6 +200,7 @@ function PackageShopCard({
isRecommended, isRecommended,
isUpgrade, isUpgrade,
isDowngrade, isDowngrade,
catalogType,
onSelect onSelect
}: { }: {
pkg: Package; pkg: Package;
@@ -176,14 +209,17 @@ function PackageShopCard({
isRecommended?: any; isRecommended?: any;
isUpgrade?: boolean; isUpgrade?: boolean;
isDowngrade?: boolean; isDowngrade?: boolean;
catalogType: 'endcustomer' | 'reseller';
onSelect: () => void onSelect: () => void
}) { }) {
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const isResellerCatalog = catalogType === 'reseller';
const statusLabel = getPackageStatusLabel({ t, isActive, owned }); const statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive); const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
const canSelect = canSelectPackage(isUpgrade, isActive); const canSelect = isResellerCatalog ? Boolean(pkg.paddle_price_id) : canSelectPackage(isUpgrade, isActive);
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
return ( return (
<MobileCard <MobileCard
@@ -202,9 +238,13 @@ function PackageShopCard({
{pkg.name} {pkg.name}
</Text> </Text>
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>} {isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
{isUpgrade && !isActive ? <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> : null} {!isResellerCatalog && isUpgrade && !isActive ? (
{isDowngrade && !isActive ? <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> : null} <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>} ) : null}
{!isResellerCatalog && isDowngrade && !isActive ? (
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
) : null}
{!isResellerCatalog && isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
</XStack> </XStack>
<XStack space="$2" alignItems="center"> <XStack space="$2" alignItems="center">
@@ -224,34 +264,58 @@ function PackageShopCard({
</XStack> </XStack>
<YStack space="$1.5"> <YStack space="$1.5">
{pkg.max_photos ? ( {isResellerCatalog ? (
<FeatureRow label={t('shop.limits.photos', '{{count}} Photos', { count: pkg.max_photos })} /> <>
{includedTierLabel ? (
<FeatureRow
label={t('shop.partner.includedTier', 'Inklusive Event-Level: {{tier}}', {
tier: includedTierLabel,
})}
/>
) : null}
{typeof pkg.max_events_per_year === 'number' ? (
<FeatureRow label={t('shop.partner.eventsIncluded', '{{count}} Events im Kontingent', { count: pkg.max_events_per_year })} />
) : null}
<FeatureRow label={t('shop.partner.recommendedUsage', 'Empfohlen innerhalb von 24 Monaten zu nutzen.')} />
</>
) : ( ) : (
<FeatureRow label={t('shop.limits.unlimitedPhotos', 'Unlimited Photos')} /> <>
{pkg.max_photos ? (
<FeatureRow label={t('shop.limits.photos', '{{count}} Photos', { count: pkg.max_photos })} />
) : (
<FeatureRow label={t('shop.limits.unlimitedPhotos', 'Unlimited Photos')} />
)}
{pkg.gallery_days ? (
<FeatureRow label={t('shop.limits.days', '{{count}} Days Gallery', { count: pkg.gallery_days })} />
) : null}
</>
)} )}
{pkg.gallery_days ? (
<FeatureRow label={t('shop.limits.days', '{{count}} Days Gallery', { count: pkg.gallery_days })} />
) : null}
{/* Render specific feature if it was requested */} {/* Render specific feature if it was requested */}
{getEnabledPackageFeatures(pkg) {!isResellerCatalog
.filter((key) => !pkg.max_photos || key !== 'photos') ? getEnabledPackageFeatures(pkg)
.slice(0, 3) .filter((key) => !pkg.max_photos || key !== 'photos')
.map((key) => ( .slice(0, 3)
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} /> .map((key) => (
))} <FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))
: null}
</YStack> </YStack>
<CTAButton <CTAButton
label={ label={
isActive isResellerCatalog
? t('shop.manage', 'Manage Plan') ? canSelect
: isUpgrade ? t('shop.partner.buy', 'Kaufen')
? t('shop.select', 'Select') : t('shop.partner.unavailable', 'Nicht verfügbar')
: t('shop.selectDisabled', 'Not available') : isActive
? t('shop.manage', 'Manage Plan')
: isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available')
} }
onPress={canSelect ? onSelect : undefined} onPress={canSelect ? onSelect : undefined}
tone={isActive || !isUpgrade ? 'ghost' : 'primary'} tone={isResellerCatalog ? (canSelect ? 'primary' : 'ghost') : isActive || !isUpgrade ? 'ghost' : 'primary'}
disabled={!canSelect} disabled={!canSelect}
/> />
</MobileCard> </MobileCard>
@@ -280,9 +344,11 @@ type PackageEntry = {
function PackageShopCompareView({ function PackageShopCompareView({
entries, entries,
onSelect, onSelect,
catalogType,
}: { }: {
entries: PackageEntry[]; entries: PackageEntry[];
onSelect: (pkg: Package) => void; onSelect: (pkg: Package) => void;
catalogType: 'endcustomer' | 'reseller';
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
@@ -308,9 +374,18 @@ function PackageShopCompareView({
if (row.limitKey === 'max_guests') { if (row.limitKey === 'max_guests') {
return t('shop.compare.rows.guests', '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'); 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); return t(`shop.features.${row.featureKey}`, row.featureKey);
}; };
@@ -362,13 +437,15 @@ function PackageShopCompareView({
{entry.isRecommended ? ( {entry.isRecommended ? (
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge> <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
) : null} ) : null}
{entry.isUpgrade && !entry.isActive ? ( {catalogType !== 'reseller' && entry.isUpgrade && !entry.isActive ? (
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
) : null} ) : null}
{entry.isDowngrade && !entry.isActive ? ( {catalogType !== 'reseller' && entry.isDowngrade && !entry.isActive ? (
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
) : null} ) : null}
{entry.isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null} {catalogType !== 'reseller' && entry.isActive ? (
<PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>
) : null}
</XStack> </XStack>
{statusLabel ? ( {statusLabel ? (
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
@@ -391,6 +468,13 @@ function PackageShopCompareView({
{formatLimitValue(value)} {formatLimitValue(value)}
</Text> </Text>
); );
} else if (row.type === 'value') {
content = (
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
{resolveIncludedTierLabel(t, entry.pkg.included_package_slug ?? null) ??
t('shop.partner.compare.values.unknown', '—')}
</Text>
);
} else if (row.type === 'feature') { } else if (row.type === 'feature') {
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey); const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
content = ( content = (
@@ -425,12 +509,17 @@ function PackageShopCompareView({
<XStack paddingTop="$2"> <XStack paddingTop="$2">
<YStack width={labelWidth} /> <YStack width={labelWidth} />
{entries.map((entry) => { {entries.map((entry) => {
const canSelect = canSelectPackage(entry.isUpgrade, entry.isActive); const isResellerCatalog = catalogType === 'reseller';
const label = entry.isActive const canSelect = isResellerCatalog ? Boolean(entry.pkg.paddle_price_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
? t('shop.manage', 'Manage Plan') const label = isResellerCatalog
: entry.isUpgrade ? canSelect
? t('shop.select', 'Select') ? t('shop.partner.buy', 'Kaufen')
: t('shop.selectDisabled', 'Not available'); : 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 ( return (
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2"> <YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
@@ -438,7 +527,15 @@ function PackageShopCompareView({
label={label} label={label}
onPress={canSelect ? () => onSelect(entry.pkg) : undefined} onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
disabled={!canSelect} disabled={!canSelect}
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'} tone={
catalogType === 'reseller'
? canSelect
? 'primary'
: 'ghost'
: entry.isActive || entry.isDowngrade
? 'ghost'
: 'primary'
}
/> />
</YStack> </YStack>
); );
@@ -488,11 +585,16 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
await startCheckout(pkg.id); await startCheckout(pkg.id);
}; };
const subtitle =
pkg.type === 'reseller'
? t('shop.partner.confirmSubtitle', 'Du kaufst:')
: t('shop.confirmSubtitle', 'You are upgrading to:');
return ( return (
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile"> <MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
<YStack space="$4"> <YStack space="$4">
<MobileCard space="$2" borderColor={border}> <MobileCard space="$2" borderColor={border}>
<Text fontSize="$sm" color={muted}>{t('shop.confirmSubtitle', 'You are upgrading to:')}</Text> <Text fontSize="$sm" color={muted}>{subtitle}</Text>
<Text fontSize="$xl" fontWeight="800" color={textStrong}>{pkg.name}</Text> <Text fontSize="$xl" fontWeight="800" color={textStrong}>{pkg.name}</Text>
<Text fontSize="$lg" color={primary} fontWeight="700"> <Text fontSize="$lg" color={primary} fontWeight="700">
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)} {new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
@@ -556,3 +658,43 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
</MobileShell> </MobileShell>
); );
} }
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, unknown>) => 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;
}

View File

@@ -6,6 +6,8 @@ const basePackage: TenantPackageSummary = {
id: 1, id: 1,
package_id: 1, package_id: 1,
package_name: 'Pro', package_name: 'Pro',
package_type: 'reseller',
included_package_slug: 'pro',
active: true, active: true,
used_events: 2, used_events: 2,
remaining_events: 3, remaining_events: 3,

View File

@@ -9,7 +9,12 @@ export type PackageComparisonRow =
| { | {
id: string; id: string;
type: 'limit'; 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; id: string;
@@ -62,6 +67,10 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac
return { isUpgrade: false, isDowngrade: false }; return { isUpgrade: false, isDowngrade: false };
} }
if (pkg.type === 'reseller' || active.type === 'reseller') {
return { isUpgrade: false, isDowngrade: false };
}
const activeFeatures = collectFeatures(active); const activeFeatures = collectFeatures(active);
const candidateFeatures = collectFeatures(pkg); const candidateFeatures = collectFeatures(pkg);
@@ -106,6 +115,10 @@ export function selectRecommendedPackageId(
return null; return null;
} }
if (packages.some((pkg) => pkg.type === 'reseller')) {
return null;
}
const candidates = feature === 'watermark_allowed' const candidates = feature === 'watermark_allowed'
? packages.filter((pkg) => pkg.watermark_allowed === true) ? packages.filter((pkg) => pkg.watermark_allowed === true)
: packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature)); : packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
@@ -121,11 +134,20 @@ export function selectRecommendedPackageId(
} }
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] { export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
const limitRows: PackageComparisonRow[] = [ const isResellerCatalog = packages.some(
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' }, (pkg) => pkg.type === 'reseller' || pkg.max_events_per_year !== undefined || pkg.included_package_slug !== undefined
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' }, );
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
]; 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<string>(); const featureKeys = new Set<string>();
packages.forEach((pkg) => { packages.forEach((pkg) => {

View File

@@ -174,13 +174,19 @@ export function formatPackageLimit(value: number | null | undefined, t: Translat
export function getPackageLimitEntries( export function getPackageLimitEntries(
limits: Record<string, unknown> | null, limits: Record<string, unknown> | null,
t: Translate, t: Translate,
usageOverrides: LimitUsageOverrides = {} usageOverrides: LimitUsageOverrides = {},
options: { packageType?: string | null } = {}
): PackageLimitEntry[] { ): PackageLimitEntry[] {
if (!limits) { if (!limits) {
return []; 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, key,
label: t(labelKey, fallback), label: t(labelKey, fallback),
value: formatLimitWithRemaining( 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'); features.add('branding_allowed');
} }
if (pkg.watermark_allowed) { if (pkg.package_type !== 'reseller' && pkg.watermark_allowed) {
features.add('watermark_allowed'); features.add('watermark_allowed');
} }

View File

@@ -29,7 +29,7 @@ i18n
}, },
backend: { backend: {
// Cache-bust to ensure fresh translations when files change. // Cache-bust to ensure fresh translations when files change.
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20251222', loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250116',
}, },
react: { react: {
useSuspense: true, useSuspense: true,

View File

@@ -28,6 +28,7 @@ interface Package {
price: number; price: number;
events: number | null; events: number | null;
features: string[]; features: string[];
included_package_slug?: string | null;
max_events_per_year?: number | null; max_events_per_year?: number | null;
limits?: { limits?: {
max_photos?: number; max_photos?: number;
@@ -62,9 +63,10 @@ const sortPackagesByPrice = (packages: Package[]): Package[] =>
interface PackageComparisonProps { interface PackageComparisonProps {
packages: Package[]; packages: Package[];
variant: 'endcustomer' | 'reseller'; variant: 'endcustomer' | 'reseller';
serviceTierNames?: Record<string, string>;
} }
const buildDisplayFeatures = (pkg: Package): string[] => { const buildDisplayFeatures = (pkg: Package, variant: 'endcustomer' | 'reseller'): string[] => {
const features = [...pkg.features]; const features = [...pkg.features];
const removeFeature = (key: string) => { const removeFeature = (key: string) => {
@@ -80,20 +82,22 @@ const buildDisplayFeatures = (pkg: Package): string[] => {
} }
}; };
const watermarkFeature = resolveWatermarkFeatureKey(pkg); if (variant === 'endcustomer') {
['watermark', 'no_watermark', 'watermark_base', 'watermark_custom'].forEach(removeFeature); const watermarkFeature = resolveWatermarkFeatureKey(pkg);
addFeature(watermarkFeature); ['watermark', 'no_watermark', 'watermark_base', 'watermark_custom'].forEach(removeFeature);
addFeature(watermarkFeature);
if (pkg.branding_allowed) { if (pkg.branding_allowed) {
addFeature('custom_branding'); addFeature('custom_branding');
} else { } else {
removeFeature('custom_branding'); removeFeature('custom_branding');
}
} }
return Array.from(new Set(features)); return Array.from(new Set(features));
}; };
function PackageComparison({ packages, variant }: PackageComparisonProps) { function PackageComparison({ packages, variant, serviceTierNames = {} }: PackageComparisonProps) {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common'); const { t: tCommon } = useTranslation('common');
@@ -135,12 +139,19 @@ function PackageComparison({ packages, variant }: PackageComparisonProps) {
{ {
key: 'price', key: 'price',
label: t('packages.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', key: 'included_package_slug',
label: t('packages.max_tenants'), label: t('packages.included_package_label', 'Inklusive Event-Level'),
value: (pkg: Package) => pkg.limits?.max_tenants?.toLocaleString() ?? tCommon('unlimited'), value: (pkg: Package) => {
const slug = pkg.included_package_slug ?? null;
if (!slug) {
return tCommon('unlimited');
}
return serviceTierNames[slug] ?? slug;
},
}, },
{ {
key: 'max_events_per_year', key: 'max_events_per_year',
@@ -150,24 +161,51 @@ function PackageComparison({ packages, variant }: PackageComparisonProps) {
}, },
]; ];
const features = [ const features =
{ variant === 'endcustomer'
key: 'watermark', ? [
label: t('packages.watermark_label'), {
value: (pkg: Package) => t(`packages.feature_${resolveWatermarkFeatureKey(pkg)}`), 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: '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) => key: 'support',
pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -258,6 +296,15 @@ interface PackagesProps {
const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => { const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null); const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
const serviceTierNames = useMemo(() => {
const map: Record<string, string> = {};
endcustomerPackages.forEach((pkg) => {
if (pkg?.slug) {
map[pkg.slug] = pkg.name;
}
});
return map;
}, [endcustomerPackages]);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const dialogScrollRef = useRef<HTMLDivElement | null>(null); const dialogScrollRef = useRef<HTMLDivElement | null>(null);
const dialogHeadingRef = useRef<HTMLDivElement | null>(null); const dialogHeadingRef = useRef<HTMLDivElement | null>(null);
@@ -499,6 +546,26 @@ type PackageMetric = {
value: string; 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 = ( const resolvePackageMetrics = (
pkg: Package, pkg: Package,
variant: 'endcustomer' | 'reseller', variant: 'endcustomer' | 'reseller',
@@ -508,11 +575,9 @@ const resolvePackageMetrics = (
if (variant === 'reseller') { if (variant === 'reseller') {
return [ return [
{ {
key: 'max_tenants', key: 'included_package_slug',
label: t('packages.max_tenants'), label: t('packages.included_package_label', 'Inklusive Event-Level'),
value: pkg.limits?.max_tenants value: resolveServiceTierLabel(pkg.included_package_slug) || tCommon('unlimited'),
? pkg.limits.max_tenants.toLocaleString()
: tCommon('unlimited'),
}, },
{ {
key: 'max_events_per_year', key: 'max_events_per_year',
@@ -522,9 +587,9 @@ const resolvePackageMetrics = (
: tCommon('unlimited'), : tCommon('unlimited'),
}, },
{ {
key: 'branding', key: 'recommended_usage_window',
label: t('packages.feature_custom_branding'), label: t('packages.recommended_usage_label', 'Empfehlung'),
value: pkg.branding_allowed ? tCommon('included') : t('packages.feature_no_branding'), value: t('packages.recommended_usage_window'),
}, },
]; ];
} }
@@ -588,7 +653,7 @@ function PackageCard({
: `${numericPrice.toLocaleString()} ${t('packages.currency.euro')}`; : `${numericPrice.toLocaleString()} ${t('packages.currency.euro')}`;
const cadenceLabel = const cadenceLabel =
variant === 'reseller' variant === 'reseller'
? t('packages.billing_per_year') ? t('packages.billing_per_kontingent')
: t('packages.billing_per_event'); : t('packages.billing_per_event');
const typeLabel = const typeLabel =
variant === 'reseller' ? t('packages.subscription') : t('packages.one_time'); variant === 'reseller' ? t('packages.subscription') : t('packages.one_time');
@@ -601,7 +666,7 @@ function PackageCard({
? t('packages.badge_starter') ? t('packages.badge_starter')
: null; : null;
const displayFeatures = buildDisplayFeatures(pkg); const displayFeatures = buildDisplayFeatures(pkg, variant);
const keyFeatures = displayFeatures.slice(0, 3); const keyFeatures = displayFeatures.slice(0, 3);
const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5); const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5);
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon); const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
@@ -736,8 +801,8 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
}) => { }) => {
const metrics = resolvePackageMetrics(packageData, variant, t, tCommon); const metrics = resolvePackageMetrics(packageData, variant, t, tCommon);
const highlightFeatures = useMemo( const highlightFeatures = useMemo(
() => buildDisplayFeatures(packageData).slice(0, 5), () => buildDisplayFeatures(packageData, variant).slice(0, 5),
[packageData], [packageData, variant],
); );
return ( return (
@@ -756,7 +821,7 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
</p> </p>
{packageData.price > 0 && ( {packageData.price > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-300"> <p className="text-sm text-gray-500 dark:text-gray-300">
/ {variant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')} / {variant === 'reseller' ? t('packages.billing_per_kontingent') : t('packages.billing_per_event')}
</p> </p>
)} )}
</div> </div>
@@ -1015,7 +1080,7 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
/> />
))} ))}
</div> </div>
<PackageComparison packages={orderedResellerPackages} variant="reseller" /> <PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@@ -46,7 +46,7 @@ return [
'emotion' => 'Emotion', 'emotion' => 'Emotion',
'event_type' => 'Eventtyp', 'event_type' => 'Eventtyp',
'last_activity' => 'Letzte Aktivität', 'last_activity' => 'Letzte Aktivität',
'credits' => 'Credits', 'credits' => 'Event-Kontingent',
'settings' => 'Einstellungen', 'settings' => 'Einstellungen',
'join' => 'Beitreten', 'join' => 'Beitreten',
'unnamed' => 'Ohne Namen', 'unnamed' => 'Ohne Namen',
@@ -517,7 +517,7 @@ return [
'heading' => 'Uploads (14 Tage)', 'heading' => 'Uploads (14 Tage)',
], ],
'credit_alerts' => [ 'credit_alerts' => [
'low_balance_label' => 'Mandanten mit niedrigen Credits', 'low_balance_label' => 'Mandanten mit niedrigem Event-Kontingent',
'low_balance_desc' => 'Benötigen Betreuung', 'low_balance_desc' => 'Benötigen Betreuung',
'monthly_revenue_label' => 'Umsatz (Monat)', 'monthly_revenue_label' => 'Umsatz (Monat)',
'monthly_revenue_desc' => 'Aktueller Monat (:month)', 'monthly_revenue_desc' => 'Aktueller Monat (:month)',
@@ -546,10 +546,10 @@ return [
'name' => 'Mandantenname', 'name' => 'Mandantenname',
'slug' => 'Slug', 'slug' => 'Slug',
'contact_email' => 'KontaktEMail', 'contact_email' => 'KontaktEMail',
'event_credits_balance' => 'EventCreditsKontostand', 'event_credits_balance' => 'Event-Kontingent',
'features' => 'Funktionen', 'features' => 'Funktionen',
'total_revenue' => 'Gesamtumsatz', 'total_revenue' => 'Gesamtumsatz',
'active_reseller_package' => 'Aktives Reseller-Paket', 'active_reseller_package' => 'Aktives Partner / Agentur-Paket',
'remaining_events' => 'Verbleibende Events', 'remaining_events' => 'Verbleibende Events',
'package_expires_at' => 'Ablaufdatum Paket', 'package_expires_at' => 'Ablaufdatum Paket',
'is_active' => 'Aktiv', 'is_active' => 'Aktiv',
@@ -574,12 +574,12 @@ return [
'timeline' => 'Audit Timeline', 'timeline' => 'Audit Timeline',
], ],
'actions' => [ 'actions' => [
'adjust_credits' => 'Credits anpassen', 'adjust_credits' => 'Kontingent anpassen',
'adjust_credits_delta' => 'Anzahl Credits (positiv/negativ)', 'adjust_credits_delta' => 'Event-Kontingent (positiv/negativ)',
'adjust_credits_delta_hint' => 'Positive Werte fügen Credits hinzu, negative Werte ziehen ab.', 'adjust_credits_delta_hint' => 'Positive Werte fügen Kontingent hinzu, negative Werte ziehen ab.',
'adjust_credits_reason' => 'Interne Notiz', 'adjust_credits_reason' => 'Interne Notiz',
'adjust_credits_success_title' => 'Credits aktualisiert', 'adjust_credits_success_title' => 'Kontingent aktualisiert',
'adjust_credits_success_body' => 'Die Credits wurden um :delta verändert. Neuer Kontostand: :balance.', 'adjust_credits_success_body' => 'Das Kontingent wurde um :delta verändert. Neuer Stand: :balance.',
'lifecycle' => 'Lebenszyklus', 'lifecycle' => 'Lebenszyklus',
'activate' => 'Aktivieren', 'activate' => 'Aktivieren',
'deactivate' => 'Deaktivieren', 'deactivate' => 'Deaktivieren',
@@ -663,7 +663,7 @@ return [
'fields' => [ 'fields' => [
'tenant' => 'Mandant', 'tenant' => 'Mandant',
'package' => 'Paket', 'package' => 'Paket',
'credits' => 'Credits', 'credits' => 'Event-Kontingent',
'price' => 'Preis', 'price' => 'Preis',
'currency' => 'Währung', 'currency' => 'Währung',
'platform' => 'Plattform', 'platform' => 'Plattform',

View File

@@ -14,4 +14,38 @@ return [
'default_title' => 'Zugang verweigert', 'default_title' => 'Zugang verweigert',
'default_message' => 'Mit diesem QR-Zugang konnte kein Zugriff gewährt werden.', '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.',
],
],
]; ];

View File

@@ -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.", "hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
"cta_explore": "Pakete entdecken", "cta_explore": "Pakete entdecken",
"tab_endcustomer": "Endkunden", "tab_endcustomer": "Endkunden",
"tab_reseller": "Reseller & Agenturen", "tab_reseller": "Partner / Agentur",
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)", "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", "free": "Kostenlos",
"one_time": "Einmalkauf", "one_time": "Einmalkauf",
"subscription": "Abo", "subscription": "Einmalkauf",
"year": "Jahr", "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 PreisLeistungsVerhä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_photos": "Fotos",
"max_guests": "Gäste", "max_guests": "Gäste",
"gallery_days": "Tage Galerie", "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", "buy_now": "Jetzt kaufen",
"subscribe_now": "Jetzt abonnieren", "subscribe_now": "Jetzt kaufen",
"register_buy": "Registrieren und kaufen", "register_buy": "Registrieren und kaufen",
"register_subscribe": "Registrieren und abonnieren", "register_subscribe": "Registrieren und kaufen",
"faq_title": "Häufige Fragen zu Packages", "faq_title": "Häufige Fragen zu Packages",
"faq_q1": "Was ist ein Package?", "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.", "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_custom_branding": "Benutzerdefiniertes Branding",
"feature_advanced_reporting": "Erweiterte Berichterstattung", "feature_advanced_reporting": "Erweiterte Berichterstattung",
"for_endcustomers": "Für Endkunden", "for_endcustomers": "Für Endkunden",
"for_resellers": "Für Reseller", "for_resellers": "Für Partner / Agenturen",
"details_show": "Details anzeigen", "details_show": "Details anzeigen",
"comparison_title": "Packages vergleichen", "comparison_title": "Packages vergleichen",
"price": "Preis", "price": "Preis",
@@ -104,10 +117,10 @@
"no_watermark": "Kein Wasserzeichen", "no_watermark": "Kein Wasserzeichen",
"custom_branding": "Benutzerdefiniertes Branding", "custom_branding": "Benutzerdefiniertes Branding",
"max_tenants": "Max. Tenants", "max_tenants": "Max. Tenants",
"max_events": "Max. Events/Jahr", "max_events": "Events im Kontingent",
"faq_free": "Was ist das Free Package?", "faq_free": "Was ist das Free Package?",
"faq_upgrade": "Kann ich upgraden?", "faq_upgrade": "Kann ich upgraden?",
"faq_reseller": "Was für Reseller?", "faq_reseller": "Was für Partner / Agenturen?",
"faq_payment": "Zahlung sicher?" "faq_payment": "Zahlung sicher?"
}, },
"blog": { "blog": {

View File

@@ -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.', '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', 'cta_explore' => 'Packages entdecken',
'tab_endcustomer' => 'Endkunden', 'tab_endcustomer' => 'Endkunden',
'tab_reseller' => 'Reseller & Agenturen', 'tab_reseller' => 'Partner / Agenturen',
'section_endcustomer' => 'Packages für Endkunden (Einmalkauf pro Event)', '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', 'free' => 'Kostenlos',
'one_time' => 'Einmalkauf', 'one_time' => 'Einmalkauf',
'subscription' => 'Abo', 'subscription' => 'Event-Kontingent',
'year' => 'Jahr', 'year' => 'Jahr',
'max_photos' => 'Fotos', 'max_photos' => 'Fotos',
'max_guests' => 'Gäste', 'max_guests' => 'Gäste',
'gallery_days' => 'Tage Galerie', 'gallery_days' => 'Tage Galerie',
'max_events_year' => 'Events/Jahr', 'max_events_year' => 'Events enthalten',
'buy_now' => 'Jetzt kaufen', 'buy_now' => 'Jetzt kaufen',
'subscribe_now' => 'Jetzt abonnieren', 'subscribe_now' => 'Event-Kontingent kaufen',
'register_buy' => 'Registrieren und kaufen', 'register_buy' => 'Registrieren und kaufen',
'register_subscribe' => 'Registrieren und abonnieren', 'register_subscribe' => 'Registrieren und kaufen',
'faq_title' => 'Häufige Fragen zu Packages', 'faq_title' => 'Häufige Fragen zu Packages',
'faq_q1' => 'Was ist ein Package?', '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.', '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_limited_sharing' => 'Begrenztes Teilen',
'feature_no_branding' => 'Kein Branding', 'feature_no_branding' => 'Kein Branding',
'feature_0' => 'Basis-Feature', 'feature_0' => 'Basis-Feature',
'feature_reseller_dashboard' => 'Reseller-Dashboard', 'feature_reseller_dashboard' => 'Partner-Dashboard',
'feature_custom_branding' => 'Benutzerdefiniertes Branding', 'feature_custom_branding' => 'Benutzerdefiniertes Branding',
'feature_advanced_reporting' => 'Erweiterte Berichterstattung', 'feature_advanced_reporting' => 'Erweiterte Berichterstattung',
'badge_most_popular' => 'Beliebteste Wahl', 'badge_most_popular' => 'Beliebteste Wahl',
@@ -57,10 +57,12 @@ return [
'badge_starter' => 'Perfekt für den Start', 'badge_starter' => 'Perfekt für den Start',
'billing_per_event' => 'pro Event', 'billing_per_event' => 'pro Event',
'billing_per_year' => 'pro Jahr', 'billing_per_year' => 'pro Jahr',
'billing_per_kontingent' => 'pro Kontingent',
'more_features' => '+:count weitere Features', 'more_features' => '+:count weitere Features',
'max_photos_label' => 'Max. Fotos', 'max_photos_label' => 'Max. Fotos',
'max_guests_label' => 'Max. Gäste', 'max_guests_label' => 'Max. Gäste',
'gallery_days_label' => 'Galerie-Tage', 'gallery_days_label' => 'Galerie-Tage',
'recommended_usage_window' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.',
'feature_overview' => 'Feature-Überblick', 'feature_overview' => 'Feature-Überblick',
'order_hint' => 'Sofort startklar keine versteckten Kosten, sichere Zahlung über Paddle.', 'order_hint' => 'Sofort startklar keine versteckten Kosten, sichere Zahlung über Paddle.',
'features_label' => 'Features', 'features_label' => 'Features',
@@ -109,7 +111,7 @@ return [
'summary_title' => 'Ihre Bestellung', 'summary_title' => 'Ihre Bestellung',
'package_label' => 'Ausgewähltes Package', 'package_label' => 'Ausgewähltes Package',
'billing_type_one_time' => 'Einmalkauf (pro Event)', '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', 'legal_links_intro' => 'Mit Abschluss des Kaufs akzeptieren Sie unsere',
'link_terms' => 'AGB', 'link_terms' => 'AGB',
'link_privacy' => 'Datenschutzerklärung', '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_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_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.', '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' => [ 'legal' => [
'imprint' => 'Impressum', 'imprint' => 'Impressum',

View File

@@ -46,7 +46,7 @@ return [
'emotion' => 'Emotion', 'emotion' => 'Emotion',
'event_type' => 'Event Type', 'event_type' => 'Event Type',
'last_activity' => 'Last activity', 'last_activity' => 'Last activity',
'credits' => 'Credits', 'credits' => 'Event kontingent',
'settings' => 'Settings', 'settings' => 'Settings',
'join' => 'Join', 'join' => 'Join',
'unnamed' => 'Unnamed', 'unnamed' => 'Unnamed',
@@ -503,7 +503,7 @@ return [
'heading' => 'Uploads (14 days)', 'heading' => 'Uploads (14 days)',
], ],
'credit_alerts' => [ 'credit_alerts' => [
'low_balance_label' => 'Tenants with low credits', 'low_balance_label' => 'Tenants with low event kontingent',
'low_balance_desc' => 'May require follow-up', 'low_balance_desc' => 'May require follow-up',
'monthly_revenue_label' => 'Revenue (month)', 'monthly_revenue_label' => 'Revenue (month)',
'monthly_revenue_desc' => 'Current month (:month)', 'monthly_revenue_desc' => 'Current month (:month)',
@@ -532,10 +532,10 @@ return [
'name' => 'Tenant name', 'name' => 'Tenant name',
'slug' => 'Slug', 'slug' => 'Slug',
'contact_email' => 'Contact email', 'contact_email' => 'Contact email',
'event_credits_balance' => 'Event credits balance', 'event_credits_balance' => 'Event kontingent',
'features' => 'Features', 'features' => 'Features',
'total_revenue' => 'Total revenue', 'total_revenue' => 'Total revenue',
'active_reseller_package' => 'Active reseller package', 'active_reseller_package' => 'Active partner / agency package',
'remaining_events' => 'Remaining events', 'remaining_events' => 'Remaining events',
'package_expires_at' => 'Package expires at', 'package_expires_at' => 'Package expires at',
'is_active' => 'Active', 'is_active' => 'Active',
@@ -560,12 +560,12 @@ return [
'timeline' => 'Audit timeline', 'timeline' => 'Audit timeline',
], ],
'actions' => [ 'actions' => [
'adjust_credits' => 'Adjust credits', 'adjust_credits' => 'Adjust kontingent',
'adjust_credits_delta' => 'Credit delta (positive/negative)', 'adjust_credits_delta' => 'Event kontingent delta (positive/negative)',
'adjust_credits_delta_hint' => 'Positive values grant credits, negative values deduct them.', 'adjust_credits_delta_hint' => 'Positive values add kontingent, negative values deduct it.',
'adjust_credits_reason' => 'Internal note', 'adjust_credits_reason' => 'Internal note',
'adjust_credits_success_title' => 'Credits updated', 'adjust_credits_success_title' => 'Kontingent updated',
'adjust_credits_success_body' => 'Credits changed by :delta. New balance: :balance.', 'adjust_credits_success_body' => 'Kontingent changed by :delta. New balance: :balance.',
'lifecycle' => 'Lifecycle', 'lifecycle' => 'Lifecycle',
'activate' => 'Activate', 'activate' => 'Activate',
'deactivate' => 'Deactivate', 'deactivate' => 'Deactivate',
@@ -649,7 +649,7 @@ return [
'fields' => [ 'fields' => [
'tenant' => 'Tenant', 'tenant' => 'Tenant',
'package' => 'Package', 'package' => 'Package',
'credits' => 'Credits', 'credits' => 'Event kontingent',
'price' => 'Price', 'price' => 'Price',
'currency' => 'Currency', 'currency' => 'Currency',
'platform' => 'Platform', 'platform' => 'Platform',

View File

@@ -14,4 +14,38 @@ return [
'default_title' => 'Access denied', 'default_title' => 'Access denied',
'default_message' => 'We could not grant access with this QR link.', '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.',
],
],
]; ];

View File

@@ -47,21 +47,34 @@
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.", "hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.",
"cta_explore": "Discover Packages", "cta_explore": "Discover Packages",
"tab_endcustomer": "End Customers", "tab_endcustomer": "End Customers",
"tab_reseller": "Resellers & Agencies", "tab_reseller": "Partner / Agency",
"section_endcustomer": "Packages for End Customers (One-time purchase per Event)", "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", "free": "Free",
"one_time": "One-time purchase", "one_time": "One-time purchase",
"subscription": "Subscription", "subscription": "One-time purchase",
"year": "Year", "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_photos": "Photos",
"max_guests": "Guests", "max_guests": "Guests",
"gallery_days": "Gallery Days", "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", "buy_now": "Buy Now",
"subscribe_now": "Subscribe Now", "subscribe_now": "Buy Now",
"register_buy": "Register and Buy", "register_buy": "Register and Buy",
"register_subscribe": "Register and Subscribe", "register_subscribe": "Register and Buy",
"faq_title": "Frequently Asked Questions about Packages", "faq_title": "Frequently Asked Questions about Packages",
"faq_q1": "What is a Package?", "faq_q1": "What is a Package?",
"faq_a1": "A Package defines limits and features for your event, e.g. number of photos and gallery duration.", "faq_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_custom_branding": "Custom Branding",
"feature_advanced_reporting": "Advanced Reporting", "feature_advanced_reporting": "Advanced Reporting",
"for_endcustomers": "For End Customers", "for_endcustomers": "For End Customers",
"for_resellers": "For Resellers", "for_resellers": "For Partner / Agencies",
"details_show": "Show Details", "details_show": "Show Details",
"comparison_title": "Compare Packages", "comparison_title": "Compare Packages",
"price": "Price", "price": "Price",
@@ -104,10 +117,10 @@
"no_watermark": "No Watermark", "no_watermark": "No Watermark",
"custom_branding": "Custom Branding", "custom_branding": "Custom Branding",
"max_tenants": "Max. Tenants", "max_tenants": "Max. Tenants",
"max_events": "Max. Events/Year", "max_events": "Events in kontingent",
"faq_free": "What is the Free Package?", "faq_free": "What is the Free Package?",
"faq_upgrade": "Can I upgrade?", "faq_upgrade": "Can I upgrade?",
"faq_reseller": "What for Resellers?", "faq_reseller": "What for Partner / Agencies?",
"faq_payment": "Payment secure?" "faq_payment": "Payment secure?"
}, },
"blog": { "blog": {

View File

@@ -7,21 +7,21 @@ return [
'hero_description' => 'From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.', 'hero_description' => 'From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.',
'cta_explore' => 'Discover Packages', 'cta_explore' => 'Discover Packages',
'tab_endcustomer' => 'End Customers', 'tab_endcustomer' => 'End Customers',
'tab_reseller' => 'Resellers & Agencies', 'tab_reseller' => 'Partner / Agencies',
'section_endcustomer' => 'Packages for End Customers (One-time purchase per Event)', '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', 'free' => 'Free',
'one_time' => 'One-time purchase', 'one_time' => 'One-time purchase',
'subscription' => 'Subscription', 'subscription' => 'Event kontingent',
'year' => 'Year', 'year' => 'Year',
'max_photos' => 'Photos', 'max_photos' => 'Photos',
'max_guests' => 'Guests', 'max_guests' => 'Guests',
'gallery_days' => 'Gallery Days', 'gallery_days' => 'Gallery Days',
'max_events_year' => 'Events/Year', 'max_events_year' => 'Events included',
'buy_now' => 'Buy Now', 'buy_now' => 'Buy Now',
'subscribe_now' => 'Subscribe Now', 'subscribe_now' => 'Buy event kontingent',
'register_buy' => 'Register and Buy', 'register_buy' => 'Register and Buy',
'register_subscribe' => 'Register and Subscribe', 'register_subscribe' => 'Register and buy',
'faq_title' => 'Frequently Asked Questions about Packages', 'faq_title' => 'Frequently Asked Questions about Packages',
'faq_q1' => 'What is a Package?', 'faq_q1' => 'What is a Package?',
'faq_a1' => 'A Package defines limits and features for your event, e.g. number of photos and gallery duration.', 'faq_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_limited_sharing' => 'Limited Sharing',
'feature_no_branding' => 'No Branding', 'feature_no_branding' => 'No Branding',
'feature_0' => 'Basic Feature', 'feature_0' => 'Basic Feature',
'feature_reseller_dashboard' => 'Reseller Dashboard', 'feature_reseller_dashboard' => 'Partner dashboard',
'feature_custom_branding' => 'Custom Branding', 'feature_custom_branding' => 'Custom Branding',
'feature_advanced_reporting' => 'Advanced Reporting', 'feature_advanced_reporting' => 'Advanced Reporting',
'badge_most_popular' => 'Most Popular', 'badge_most_popular' => 'Most Popular',
@@ -57,6 +57,8 @@ return [
'badge_starter' => 'Perfect Starter', 'badge_starter' => 'Perfect Starter',
'billing_per_event' => 'per event', 'billing_per_event' => 'per event',
'billing_per_year' => 'per year', 'billing_per_year' => 'per year',
'billing_per_kontingent' => 'per bundle',
'recommended_usage_window' => 'Recommended to use within 24 months.',
'more_features' => '+:count more features', 'more_features' => '+:count more features',
'max_photos_label' => 'Max. photos', 'max_photos_label' => 'Max. photos',
'max_guests_label' => 'Max. guests', 'max_guests_label' => 'Max. guests',
@@ -109,7 +111,7 @@ return [
'summary_title' => 'Your order', 'summary_title' => 'Your order',
'package_label' => 'Selected package', 'package_label' => 'Selected package',
'billing_type_one_time' => 'One-time purchase (per event)', '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', 'legal_links_intro' => 'By completing your order you accept our',
'link_terms' => 'Terms & Conditions', 'link_terms' => 'Terms & Conditions',
'link_privacy' => 'Privacy Policy', '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_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_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.', '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' => [ 'legal' => [
'imprint' => 'Imprint', 'imprint' => 'Imprint',

View File

@@ -155,13 +155,17 @@ class EventControllerTest extends TenantTestCase
{ {
$tenant = $this->tenant; $tenant = $this->tenant;
$eventType = EventType::factory()->create(); $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]); $package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
TenantPackage::factory()->create([ TenantPackage::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
'used_events' => 0, 'used_events' => 0,
'active' => true, 'active' => true,
'expires_at' => now()->addYear(), 'expires_at' => null,
]); ]);
// First event succeeds // First event succeeds
@@ -175,6 +179,13 @@ class EventControllerTest extends TenantTestCase
$response1->assertStatus(201); $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 // Second event fails due to limit
$response2 = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [ $response2 = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
'name' => 'Second Event', 'name' => 'Second Event',
@@ -188,6 +199,39 @@ class EventControllerTest extends TenantTestCase
->assertJsonPath('error.code', 'event_limit_exceeded'); ->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 public function test_update_event_accepts_live_show_settings(): void
{ {
$eventType = EventType::factory()->create(); $eventType = EventType::factory()->create();

View File

@@ -34,7 +34,7 @@ class PackageLimitEvaluatorTest extends TestCase
'active' => true, 'active' => true,
]); ]);
$violation = $this->evaluator->assessEventCreation($tenant); $violation = $this->evaluator->assessEventCreation($tenant, null);
$this->assertNull($violation); $this->assertNull($violation);
} }
@@ -57,7 +57,7 @@ class PackageLimitEvaluatorTest extends TestCase
$tenant->refresh(); $tenant->refresh();
$violation = $this->evaluator->assessEventCreation($tenant); $violation = $this->evaluator->assessEventCreation($tenant, null);
$this->assertNotNull($violation); $this->assertNotNull($violation);
$this->assertSame('event_limit_exceeded', $violation['code']); $this->assertSame('event_limit_exceeded', $violation['code']);

View File

@@ -83,5 +83,6 @@ class TenantUsageTrackerTest extends TestCase
$tenantPackage->refresh(); $tenantPackage->refresh();
$this->assertNotNull($tenantPackage->event_limit_notified_at); $this->assertNotNull($tenantPackage->event_limit_notified_at);
$this->assertFalse($tenantPackage->active);
} }
} }

View File

@@ -146,4 +146,39 @@ class TenantModelTest extends TestCase
$this->assertFalse($tenant->features['analytics']); $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());
}
} }

View File

@@ -30,7 +30,7 @@ class TenantPackageTest extends TestCase
$this->assertTrue($tenantPackage->expires_at->lessThanOrEqualTo(now()->addYears(2))); $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]); $package = Package::factory()->reseller()->create(['max_events_per_year' => 5]);
@@ -41,8 +41,8 @@ class TenantPackageTest extends TestCase
$tenantPackage->refresh(); $tenantPackage->refresh();
$this->assertNotNull($tenantPackage->expires_at); $this->assertNull($tenantPackage->expires_at);
$this->assertTrue($tenantPackage->expires_at->isFuture()); $this->assertTrue($tenantPackage->isActive());
$tenantPackage->forceFill(['expires_at' => now()->subDay()])->save(); $tenantPackage->forceFill(['expires_at' => now()->subDay()])->save();