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