Fix guest demo UX and enforce guest limits
This commit is contained in:
@@ -185,6 +185,57 @@ class EventPublicController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
||||
|
||||
if ($event->id ?? null) {
|
||||
$eventModel = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])->find($event->id);
|
||||
if ($eventModel && $eventModel->tenant) {
|
||||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
|
||||
$eventModel->tenant,
|
||||
$eventModel->id,
|
||||
$eventModel
|
||||
);
|
||||
$maxGuests = $eventPackage?->effectiveGuestLimit();
|
||||
|
||||
if ($eventPackage && $maxGuests !== null) {
|
||||
$grace = (int) config('package-limits.guest_grace', 10);
|
||||
$hardLimit = $maxGuests + max(0, $grace);
|
||||
$usedGuests = (int) $eventPackage->used_guests;
|
||||
$isReturningGuest = $this->joinTokenService->hasSeenGuest($eventModel->id, $deviceId, $request->ip());
|
||||
|
||||
if ($usedGuests >= $hardLimit && ! $isReturningGuest) {
|
||||
$this->recordTokenEvent(
|
||||
$joinToken,
|
||||
$request,
|
||||
'guest_limit_exceeded',
|
||||
[
|
||||
'event_id' => $eventModel->id,
|
||||
'used' => $usedGuests,
|
||||
'limit' => $maxGuests,
|
||||
'hard_limit' => $hardLimit,
|
||||
],
|
||||
$token,
|
||||
Response::HTTP_PAYMENT_REQUIRED
|
||||
);
|
||||
|
||||
return ApiError::response(
|
||||
'guest_limit_exceeded',
|
||||
__('api.packages.guest_limit_exceeded.title'),
|
||||
__('api.packages.guest_limit_exceeded.message'),
|
||||
Response::HTTP_PAYMENT_REQUIRED,
|
||||
[
|
||||
'event_id' => $eventModel->id,
|
||||
'used' => $usedGuests,
|
||||
'limit' => $maxGuests,
|
||||
'hard_limit' => $hardLimit,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RateLimiter::clear($rateLimiterKey);
|
||||
|
||||
if (isset($event->status)) {
|
||||
@@ -1906,7 +1957,9 @@ class EventPublicController extends BaseController
|
||||
$policy = $this->guestPolicy();
|
||||
|
||||
if ($joinToken) {
|
||||
$this->joinTokenService->incrementUsage($joinToken);
|
||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
||||
$this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip());
|
||||
}
|
||||
|
||||
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use App\Models\GuestPolicySetting;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -63,7 +64,7 @@ class EventJoinTokenService
|
||||
return $joinToken;
|
||||
}
|
||||
|
||||
public function incrementUsage(EventJoinToken $joinToken): void
|
||||
public function incrementUsage(EventJoinToken $joinToken, ?string $deviceId = null, ?string $ipAddress = null): void
|
||||
{
|
||||
$joinToken->increment('usage_count');
|
||||
|
||||
@@ -78,6 +79,12 @@ class EventJoinTokenService
|
||||
$eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event);
|
||||
|
||||
if ($eventPackage && $eventPackage->package?->max_guests !== null) {
|
||||
$normalizedDeviceId = $this->normalizeDeviceId($deviceId);
|
||||
$normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null;
|
||||
if (! $this->shouldCountGuest($event->id, $normalizedDeviceId, $normalizedIp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previous = (int) $eventPackage->used_guests;
|
||||
$eventPackage->increment('used_guests');
|
||||
$eventPackage->refresh();
|
||||
@@ -87,6 +94,28 @@ class EventJoinTokenService
|
||||
}
|
||||
}
|
||||
|
||||
public function hasSeenGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool
|
||||
{
|
||||
$normalizedDeviceId = $this->normalizeDeviceId($deviceId);
|
||||
$normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null;
|
||||
|
||||
if (! $normalizedDeviceId && ! $normalizedIp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query = EventJoinTokenEvent::query()
|
||||
->where('event_id', $eventId)
|
||||
->where('event_type', 'access_granted');
|
||||
|
||||
if ($normalizedDeviceId) {
|
||||
$query->where('device_id', $normalizedDeviceId);
|
||||
} else {
|
||||
$query->where('ip_address', $normalizedIp);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
|
||||
{
|
||||
$hash = $this->hashToken($token);
|
||||
@@ -132,4 +161,35 @@ class EventJoinTokenService
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
private function normalizeDeviceId(?string $deviceId): ?string
|
||||
{
|
||||
if (! is_string($deviceId) || trim($deviceId) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleaned = preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId) ?? '';
|
||||
$cleaned = substr($cleaned, 0, 64);
|
||||
|
||||
return $cleaned !== '' ? $cleaned : null;
|
||||
}
|
||||
|
||||
private function shouldCountGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool
|
||||
{
|
||||
if (! $deviceId && ! $ipAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query = EventJoinTokenEvent::query()
|
||||
->where('event_id', $eventId)
|
||||
->where('event_type', 'access_granted');
|
||||
|
||||
if ($deviceId) {
|
||||
$query->where('device_id', $deviceId);
|
||||
} else {
|
||||
$query->where('ip_address', $ipAddress);
|
||||
}
|
||||
|
||||
return $query->count() <= 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,10 +233,13 @@ class PackageLimitEvaluator
|
||||
config('package-limits.photo_thresholds', [])
|
||||
);
|
||||
|
||||
$guestSummary = $this->buildUsageSummary(
|
||||
(int) $eventPackage->used_guests,
|
||||
$limits['max_guests'],
|
||||
config('package-limits.guest_thresholds', [])
|
||||
$guestSummary = $this->applyGuestGrace(
|
||||
$this->buildUsageSummary(
|
||||
(int) $eventPackage->used_guests,
|
||||
$limits['max_guests'],
|
||||
config('package-limits.guest_thresholds', [])
|
||||
),
|
||||
(int) $eventPackage->used_guests
|
||||
);
|
||||
|
||||
$gallerySummary = $this->buildGallerySummary(
|
||||
@@ -429,4 +432,37 @@ class PackageLimitEvaluator
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array} $summary
|
||||
* @return array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array}
|
||||
*/
|
||||
private function applyGuestGrace(array $summary, int $used): array
|
||||
{
|
||||
$limit = $summary['limit'] ?? null;
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$grace = (int) config('package-limits.guest_grace', 10);
|
||||
$hardLimit = $limit + max(0, $grace);
|
||||
|
||||
if ($used >= $hardLimit) {
|
||||
$summary['state'] = 'limit_reached';
|
||||
$summary['threshold_reached'] = 1.0;
|
||||
$summary['next_threshold'] = null;
|
||||
$summary['remaining'] = 0;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
if ($used >= $limit) {
|
||||
$summary['state'] = 'warning';
|
||||
$summary['threshold_reached'] = 1.0;
|
||||
$summary['next_threshold'] = null;
|
||||
$summary['remaining'] = 0;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ class PackageUsageTracker
|
||||
}
|
||||
|
||||
$newUsed = $eventPackage->used_guests;
|
||||
$grace = (int) config('package-limits.guest_grace', 10);
|
||||
$hardLimit = $limit + max(0, $grace);
|
||||
|
||||
$thresholds = collect(config('package-limits.guest_thresholds', []))
|
||||
->filter(fn (float $value) => $value > 0 && $value < 1)
|
||||
@@ -80,8 +82,8 @@ class PackageUsageTracker
|
||||
}
|
||||
}
|
||||
|
||||
if ($newUsed >= $limit && ($previousUsed < $limit)) {
|
||||
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $limit));
|
||||
if ($newUsed >= $hardLimit && ($previousUsed < $hardLimit)) {
|
||||
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $hardLimit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
0.8,
|
||||
0.95,
|
||||
],
|
||||
'guest_grace' => 10,
|
||||
'gallery_warning_days' => [
|
||||
7,
|
||||
1,
|
||||
|
||||
@@ -36,7 +36,7 @@ class DemoEventSeeder extends Seeder
|
||||
$events = [
|
||||
[
|
||||
'slug' => 'demo-wedding-2025',
|
||||
'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'],
|
||||
'name' => ['de' => 'Hochzeit von Klara & Ben', 'en' => "Klara & Ben's Wedding"],
|
||||
'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'],
|
||||
'date' => Carbon::now()->addMonths(3),
|
||||
'event_type' => $weddingType,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -98,7 +99,7 @@ class DemoPhotosSeeder extends Seeder
|
||||
}
|
||||
|
||||
$guestNames = $config['guest_names'];
|
||||
$photosToSeed = min($photoFiles->count(), count($guestNames));
|
||||
$photosToSeed = min($photoFiles->count(), count($guestNames), 20);
|
||||
|
||||
if ($photosToSeed === 0) {
|
||||
continue;
|
||||
@@ -143,23 +144,29 @@ class DemoPhotosSeeder extends Seeder
|
||||
$taskId = $taskIds ? $taskIds[array_rand($taskIds)] : null;
|
||||
$emotionId = $emotions->random()->id;
|
||||
|
||||
$photoData = [
|
||||
'task_id' => $taskId,
|
||||
'emotion_id' => $emotionId,
|
||||
'guest_name' => $guestName,
|
||||
'thumbnail_path' => $thumbDest,
|
||||
'likes_count' => $likes,
|
||||
'is_featured' => $i === 0,
|
||||
'metadata' => ['demo' => true],
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('photos', 'status')) {
|
||||
$photoData['status'] = 'approved';
|
||||
}
|
||||
|
||||
$photo = Photo::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'file_path' => $destPath,
|
||||
],
|
||||
[
|
||||
'task_id' => $taskId,
|
||||
'emotion_id' => $emotionId,
|
||||
'guest_name' => $guestName,
|
||||
'thumbnail_path' => $thumbDest,
|
||||
'likes_count' => $likes,
|
||||
'is_featured' => $i === 0,
|
||||
'metadata' => ['demo' => true],
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]
|
||||
$photoData
|
||||
);
|
||||
|
||||
PhotoLike::where('photo_id', $photo->id)->delete();
|
||||
|
||||
@@ -19,6 +19,7 @@ class SuperAdminSeeder extends Seeder
|
||||
'last_name' => 'Admin',
|
||||
'password' => Hash::make($password),
|
||||
'role' => 'super_admin',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant');
|
||||
@@ -58,5 +59,9 @@ class SuperAdminSeeder extends Seeder
|
||||
if ($user->tenant_id !== $tenant->id) {
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
}
|
||||
|
||||
if (! $user->email_verified_at) {
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,25 +17,26 @@ import { MobileSheet } from './components/Sheet';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
|
||||
import { getContrastingTextColor } from '@/guest/lib/color';
|
||||
import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext';
|
||||
import { getContrastingTextColor, relativeLuminance } from '@/guest/lib/color';
|
||||
|
||||
const BRANDING_FORM_DEFAULTS = {
|
||||
primary: ADMIN_COLORS.primary,
|
||||
accent: ADMIN_COLORS.accent,
|
||||
background: '#ffffff',
|
||||
surface: '#ffffff',
|
||||
mode: 'auto' as const,
|
||||
buttonStyle: 'filled' as const,
|
||||
buttonRadius: 12,
|
||||
buttonPrimary: ADMIN_COLORS.primary,
|
||||
buttonSecondary: ADMIN_COLORS.accent,
|
||||
linkColor: ADMIN_COLORS.accent,
|
||||
fontSize: 'm' as const,
|
||||
logoMode: 'upload' as const,
|
||||
logoPosition: 'left' as const,
|
||||
logoSize: 'm' as const,
|
||||
primary: DEFAULT_EVENT_BRANDING.primaryColor,
|
||||
accent: DEFAULT_EVENT_BRANDING.secondaryColor,
|
||||
background: DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||
surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||
mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto',
|
||||
buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled',
|
||||
buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12,
|
||||
buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor,
|
||||
buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor,
|
||||
linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor,
|
||||
fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm',
|
||||
logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon',
|
||||
logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left',
|
||||
logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm',
|
||||
};
|
||||
|
||||
const BRANDING_FORM_BASE: BrandingFormValues = {
|
||||
@@ -176,10 +177,22 @@ export default function MobileBrandingPage() {
|
||||
}, [tenantBrandingLoaded]);
|
||||
|
||||
const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form;
|
||||
const previewBackground = previewForm.background;
|
||||
const previewSurfaceCandidate = previewForm.surface || previewBackground;
|
||||
const backgroundLuminance = relativeLuminance(previewBackground);
|
||||
const surfaceLuminance = relativeLuminance(previewSurfaceCandidate);
|
||||
const previewSurface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06
|
||||
? backgroundLuminance >= 0.6
|
||||
? '#ffffff'
|
||||
: '#0f172a'
|
||||
: previewSurfaceCandidate;
|
||||
const previewForeground = backgroundLuminance >= 0.6 ? '#1f2937' : '#f8fafc';
|
||||
const previewMutedForeground = backgroundLuminance >= 0.6 ? '#6b7280' : '#cbd5e1';
|
||||
const previewBorder = backgroundLuminance >= 0.6 ? '#e6d9d6' : '#334155';
|
||||
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||
const previewHeadingFont = previewForm.headingFont || 'Archivo Black';
|
||||
const previewBodyFont = previewForm.bodyFont || 'Manrope';
|
||||
const previewSurfaceText = getContrastingTextColor(previewForm.surface, '#ffffff', '#0f172a');
|
||||
const previewHeadingFont = previewForm.headingFont || DEFAULT_EVENT_BRANDING.typography?.heading || DEFAULT_EVENT_BRANDING.fontFamily;
|
||||
const previewBodyFont = previewForm.bodyFont || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily;
|
||||
const previewSurfaceText = previewForeground;
|
||||
const previewScale = FONT_SIZE_SCALE[previewForm.fontSize] ?? 1;
|
||||
const previewButtonColor = previewForm.buttonPrimary || previewForm.primary;
|
||||
const previewButtonText = getContrastingTextColor(previewButtonColor, '#ffffff', '#0f172a');
|
||||
@@ -560,8 +573,8 @@ export default function MobileBrandingPage() {
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||
</Text>
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={previewForm.background} padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor={previewForm.surface} borderWidth={1} borderColor={border} overflow="hidden">
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor={previewBorder} backgroundColor={previewBackground} padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor={previewSurface} borderWidth={1} borderColor={previewBorder} overflow="hidden">
|
||||
<YStack
|
||||
height={64}
|
||||
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
|
||||
@@ -602,18 +615,18 @@ export default function MobileBrandingPage() {
|
||||
{previewTitle}
|
||||
</Text>
|
||||
<Text
|
||||
color={previewSurfaceText}
|
||||
style={{ fontFamily: previewBodyFont, opacity: 0.7, fontSize: 13 * previewScale }}
|
||||
color={previewMutedForeground}
|
||||
style={{ fontFamily: previewBodyFont, fontSize: 13 * previewScale }}
|
||||
>
|
||||
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$1">
|
||||
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} />
|
||||
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} />
|
||||
<ColorSwatch color={previewForm.background} label={t('events.branding.background', 'Background')} />
|
||||
<ColorSwatch color={previewForm.surface} label={t('events.branding.surface', 'Surface')} />
|
||||
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} borderColor={previewBorder} />
|
||||
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} borderColor={previewBorder} />
|
||||
<ColorSwatch color={previewBackground} label={t('events.branding.background', 'Background')} borderColor={previewBorder} />
|
||||
<ColorSwatch color={previewSurface} label={t('events.branding.surface', 'Surface')} borderColor={previewBorder} />
|
||||
</XStack>
|
||||
<XStack marginTop="$2">
|
||||
<div
|
||||
@@ -1220,11 +1233,11 @@ function ColorField({
|
||||
);
|
||||
}
|
||||
|
||||
function ColorSwatch({ color, label }: { color: string; label: string }) {
|
||||
function ColorSwatch({ color, label, borderColor }: { color: string; label: string; borderColor?: string }) {
|
||||
const { border, muted } = useAdminTheme();
|
||||
return (
|
||||
<YStack alignItems="center" space="$1">
|
||||
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={border} backgroundColor={color} />
|
||||
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={borderColor ?? border} backgroundColor={color} />
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -34,6 +34,24 @@ describe('classifyPackageChange', () => {
|
||||
const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||
});
|
||||
|
||||
it('treats unlimited sharing as an upgrade over limited sharing', () => {
|
||||
const starter = { ...active, id: 5, features: ['limited_sharing'], max_photos: 100 } as any;
|
||||
const standard = { ...active, id: 6, features: ['unlimited_sharing'], max_photos: 100 } as any;
|
||||
expect(classifyPackageChange(standard, starter)).toEqual({ isUpgrade: true, isDowngrade: false });
|
||||
});
|
||||
|
||||
it('treats watermark removal as an upgrade over base watermark', () => {
|
||||
const base = { ...active, id: 7, watermark_allowed: false, features: [] } as any;
|
||||
const removal = { ...active, id: 8, watermark_allowed: true, features: ['no_watermark'] } as any;
|
||||
expect(classifyPackageChange(removal, base)).toEqual({ isUpgrade: true, isDowngrade: false });
|
||||
});
|
||||
|
||||
it('keeps watermark custom as higher than watermark removal', () => {
|
||||
const custom = { ...active, id: 9, watermark_allowed: true, features: [] } as any;
|
||||
const removal = { ...active, id: 10, watermark_allowed: true, features: ['no_watermark'] } as any;
|
||||
expect(classifyPackageChange(removal, custom)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectRecommendedPackageId', () => {
|
||||
|
||||
@@ -63,6 +63,20 @@ function collectFeatures(pkg: Package | null): Set<string> {
|
||||
return new Set(getEnabledPackageFeatures(pkg));
|
||||
}
|
||||
|
||||
const FEATURE_COMPATIBILITY: Record<string, string[]> = {
|
||||
limited_sharing: ['limited_sharing', 'unlimited_sharing'],
|
||||
watermark_base: ['watermark_base', 'watermark_custom', 'no_watermark'],
|
||||
};
|
||||
|
||||
function featureSatisfied(feature: string, candidateFeatures: Set<string>): boolean {
|
||||
const compatible = FEATURE_COMPATIBILITY[feature];
|
||||
if (compatible) {
|
||||
return compatible.some((value) => candidateFeatures.has(value));
|
||||
}
|
||||
|
||||
return candidateFeatures.has(feature);
|
||||
}
|
||||
|
||||
function compareLimit(candidate: number | null, active: number | null): number {
|
||||
if (active === null) {
|
||||
return candidate === null ? 0 : -1;
|
||||
@@ -89,8 +103,8 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac
|
||||
const activeFeatures = collectFeatures(active);
|
||||
const candidateFeatures = collectFeatures(pkg);
|
||||
|
||||
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
|
||||
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
|
||||
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !featureSatisfied(feature, activeFeatures));
|
||||
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !featureSatisfied(feature, candidateFeatures));
|
||||
|
||||
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
|
||||
let hasLimitUpgrade = false;
|
||||
|
||||
57
resources/js/guest/components/DemoReadOnlyNotice.tsx
Normal file
57
resources/js/guest/components/DemoReadOnlyNotice.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { motion, type HTMLMotionProps } from 'framer-motion';
|
||||
import { ZapOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type DemoReadOnlyNoticeProps = {
|
||||
title: string;
|
||||
copy: string;
|
||||
hint?: string;
|
||||
ctaLabel?: string;
|
||||
onCta?: () => void;
|
||||
radius?: number;
|
||||
bodyFont?: string;
|
||||
motionProps?: HTMLMotionProps<'div'>;
|
||||
};
|
||||
|
||||
export default function DemoReadOnlyNotice({
|
||||
title,
|
||||
copy,
|
||||
hint,
|
||||
ctaLabel,
|
||||
onCta,
|
||||
radius,
|
||||
bodyFont,
|
||||
motionProps,
|
||||
}: DemoReadOnlyNoticeProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-[28px] border border-white/15 bg-black/70 p-5 text-white shadow-2xl backdrop-blur"
|
||||
style={{ borderRadius: radius, fontFamily: bodyFont }}
|
||||
{...motionProps}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10">
|
||||
<ZapOff className="h-5 w-5 text-amber-200" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-xs text-white/80">{copy}</p>
|
||||
{hint ? <p className="text-[11px] text-white/60">{hint}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{ctaLabel && onCta ? (
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="rounded-full bg-white/90 text-slate-900 hover:bg-white"
|
||||
onClick={onCta}
|
||||
>
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -40,10 +40,10 @@ const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: st
|
||||
|
||||
type LogoSize = 's' | 'm' | 'l';
|
||||
|
||||
const LOGO_SIZE_CLASSES: Record<LogoSize, { container: string; image: string; emoji: string; icon: string }> = {
|
||||
s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4' },
|
||||
m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5' },
|
||||
l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6' },
|
||||
const LOGO_SIZE_CLASSES: Record<LogoSize, { container: string; image: string; emoji: string; icon: string; initials: string }> = {
|
||||
s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4', initials: 'text-[11px]' },
|
||||
m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5', initials: 'text-sm' },
|
||||
l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6', initials: 'text-base' },
|
||||
};
|
||||
|
||||
function getLogoClasses(size?: LogoSize) {
|
||||
@@ -81,18 +81,36 @@ function getInitials(name: string): string {
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function renderEventAvatar(
|
||||
name: string,
|
||||
icon: unknown,
|
||||
accentColor: string,
|
||||
textColor: string,
|
||||
logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize }
|
||||
) {
|
||||
function EventAvatar({
|
||||
name,
|
||||
icon,
|
||||
accentColor,
|
||||
textColor,
|
||||
logo,
|
||||
}: {
|
||||
name: string;
|
||||
icon: unknown;
|
||||
accentColor: string;
|
||||
textColor: string;
|
||||
logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize };
|
||||
}) {
|
||||
const logoValue = logo?.mode === 'upload' ? (logo.value?.trim() || null) : null;
|
||||
const [logoFailed, setLogoFailed] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLogoFailed(false);
|
||||
}, [logoValue]);
|
||||
|
||||
const sizes = getLogoClasses(logo?.size);
|
||||
if (logo?.mode === 'upload' && logo.value) {
|
||||
if (logo?.mode === 'upload' && logoValue && !logoFailed) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center rounded-full bg-white shadow-sm ${sizes.container}`}>
|
||||
<img src={logo.value} alt={name} className={`rounded-full object-contain ${sizes.image}`} />
|
||||
<img
|
||||
src={logoValue}
|
||||
alt={name}
|
||||
className={`rounded-full object-contain ${sizes.image}`}
|
||||
onError={() => setLogoFailed(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +158,7 @@ function renderEventAvatar(
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full font-semibold text-sm shadow-sm"
|
||||
className={`flex items-center justify-center rounded-full font-semibold shadow-sm ${sizes.container} ${sizes.initials}`}
|
||||
style={{ backgroundColor: accentColor, color: textColor }}
|
||||
>
|
||||
{getInitials(name)}
|
||||
@@ -248,20 +266,26 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
: 'flex items-center gap-3'
|
||||
}
|
||||
>
|
||||
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)}
|
||||
<EventAvatar
|
||||
name={event.name}
|
||||
icon={event.type?.icon}
|
||||
accentColor={accentColor}
|
||||
textColor={headerTextColor}
|
||||
logo={branding.logo}
|
||||
/>
|
||||
<div
|
||||
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
|
||||
style={headerFont ? { fontFamily: headerFont } : undefined}
|
||||
>
|
||||
<div className="font-semibold text-lg">{event.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-white/70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||
<div className="flex items-center gap-2 text-xs opacity-70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||
{stats && tasksEnabled && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="opacity-50">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">{stats.tasksSolved}</span>{' '}
|
||||
{t('header.stats.tasksSolved')}
|
||||
@@ -356,7 +380,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
||||
className="relative rounded-full bg-white/15 p-2 text-current transition hover:bg-white/30"
|
||||
aria-label={open ? t('header.notifications.close', 'Benachrichtigungen schließen') : t('header.notifications.open', 'Benachrichtigungen anzeigen')}
|
||||
>
|
||||
<Bell className="h-5 w-5" aria-hidden />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo } from 'react';
|
||||
import type { EventBranding } from '../types/event-branding';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
||||
|
||||
type EventBrandingContextValue = {
|
||||
branding: EventBranding;
|
||||
@@ -7,16 +9,16 @@ type EventBrandingContextValue = {
|
||||
};
|
||||
|
||||
export const DEFAULT_EVENT_BRANDING: EventBranding = {
|
||||
primaryColor: '#FF5A5F',
|
||||
secondaryColor: '#FFF8F5',
|
||||
backgroundColor: '#FFF8F5',
|
||||
primaryColor: '#E94B5A',
|
||||
secondaryColor: '#F7C7CF',
|
||||
backgroundColor: '#FFF6F2',
|
||||
fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
logoUrl: null,
|
||||
palette: {
|
||||
primary: '#FF5A5F',
|
||||
secondary: '#FFF8F5',
|
||||
background: '#FFF8F5',
|
||||
surface: '#FFF8F5',
|
||||
primary: '#E94B5A',
|
||||
secondary: '#F7C7CF',
|
||||
background: '#FFF6F2',
|
||||
surface: '#FFFFFF',
|
||||
},
|
||||
typography: {
|
||||
heading: 'Playfair Display, "Times New Roman", serif',
|
||||
@@ -114,15 +116,46 @@ function applyCssVariables(branding: EventBranding) {
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const background = branding.backgroundColor;
|
||||
const surfaceCandidate = branding.palette?.surface ?? background;
|
||||
const backgroundLuminance = relativeLuminance(background);
|
||||
const surfaceLuminance = relativeLuminance(surfaceCandidate);
|
||||
const surface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06
|
||||
? backgroundLuminance >= 0.6
|
||||
? '#ffffff'
|
||||
: '#0f172a'
|
||||
: surfaceCandidate;
|
||||
const isLight = backgroundLuminance >= 0.6;
|
||||
const foreground = isLight ? '#1f2937' : '#f8fafc';
|
||||
const mutedForeground = isLight ? '#6b7280' : '#cbd5e1';
|
||||
const muted = isLight ? '#f6efec' : '#1f2937';
|
||||
const border = isLight ? '#e6d9d6' : '#334155';
|
||||
const input = isLight ? '#eadfda' : '#273247';
|
||||
const primaryForeground = getContrastingTextColor(branding.primaryColor, '#ffffff', '#0f172a');
|
||||
const secondaryForeground = getContrastingTextColor(branding.secondaryColor, '#ffffff', '#0f172a');
|
||||
root.style.setProperty('--guest-primary', branding.primaryColor);
|
||||
root.style.setProperty('--guest-secondary', branding.secondaryColor);
|
||||
root.style.setProperty('--guest-background', branding.backgroundColor);
|
||||
root.style.setProperty('--guest-surface', branding.palette?.surface ?? branding.backgroundColor);
|
||||
root.style.setProperty('--guest-background', background);
|
||||
root.style.setProperty('--guest-surface', surface);
|
||||
root.style.setProperty('--guest-button-radius', `${branding.buttons?.radius ?? 12}px`);
|
||||
root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`);
|
||||
root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor);
|
||||
root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled');
|
||||
root.style.setProperty('--guest-font-scale', String(FONT_SCALE_MAP[branding.typography?.sizePreset ?? 'm'] ?? 1));
|
||||
root.style.setProperty('--foreground', foreground);
|
||||
root.style.setProperty('--card-foreground', foreground);
|
||||
root.style.setProperty('--popover-foreground', foreground);
|
||||
root.style.setProperty('--muted', muted);
|
||||
root.style.setProperty('--muted-foreground', mutedForeground);
|
||||
root.style.setProperty('--border', border);
|
||||
root.style.setProperty('--input', input);
|
||||
root.style.setProperty('--primary', branding.primaryColor);
|
||||
root.style.setProperty('--primary-foreground', primaryForeground);
|
||||
root.style.setProperty('--secondary', branding.secondaryColor);
|
||||
root.style.setProperty('--secondary-foreground', secondaryForeground);
|
||||
root.style.setProperty('--accent', branding.secondaryColor);
|
||||
root.style.setProperty('--accent-foreground', secondaryForeground);
|
||||
root.style.setProperty('--ring', branding.primaryColor);
|
||||
|
||||
const headingFont = branding.typography?.heading ?? branding.fontFamily;
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily;
|
||||
@@ -160,9 +193,27 @@ function resetCssVariables() {
|
||||
root.style.removeProperty('--guest-font-family');
|
||||
root.style.removeProperty('--guest-body-font');
|
||||
root.style.removeProperty('--guest-heading-font');
|
||||
root.style.removeProperty('--foreground');
|
||||
root.style.removeProperty('--card-foreground');
|
||||
root.style.removeProperty('--popover-foreground');
|
||||
root.style.removeProperty('--muted');
|
||||
root.style.removeProperty('--muted-foreground');
|
||||
root.style.removeProperty('--border');
|
||||
root.style.removeProperty('--input');
|
||||
root.style.removeProperty('--primary');
|
||||
root.style.removeProperty('--primary-foreground');
|
||||
root.style.removeProperty('--secondary');
|
||||
root.style.removeProperty('--secondary-foreground');
|
||||
root.style.removeProperty('--accent');
|
||||
root.style.removeProperty('--accent-foreground');
|
||||
root.style.removeProperty('--ring');
|
||||
}
|
||||
|
||||
function applyThemeMode(mode: EventBranding['mode']) {
|
||||
function applyThemeMode(
|
||||
mode: EventBranding['mode'],
|
||||
backgroundColor: string,
|
||||
appearanceOverride: 'light' | 'dark' | null,
|
||||
) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -171,6 +222,12 @@ function applyThemeMode(mode: EventBranding['mode']) {
|
||||
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false;
|
||||
const backgroundLuminance = relativeLuminance(backgroundColor || DEFAULT_EVENT_BRANDING.backgroundColor);
|
||||
const backgroundPrefers = backgroundLuminance >= 0.65
|
||||
? 'light'
|
||||
: backgroundLuminance <= 0.35
|
||||
? 'dark'
|
||||
: null;
|
||||
const applyDark = () => root.classList.add('dark');
|
||||
const applyLight = () => root.classList.remove('dark');
|
||||
|
||||
@@ -186,6 +243,28 @@ function applyThemeMode(mode: EventBranding['mode']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appearanceOverride) {
|
||||
if (appearanceOverride === 'dark') {
|
||||
applyDark();
|
||||
root.style.colorScheme = 'dark';
|
||||
} else {
|
||||
applyLight();
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (backgroundPrefers) {
|
||||
if (backgroundPrefers === 'dark') {
|
||||
applyDark();
|
||||
root.style.colorScheme = 'dark';
|
||||
} else {
|
||||
applyLight();
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefersDark) {
|
||||
applyDark();
|
||||
root.style.colorScheme = 'dark';
|
||||
@@ -204,6 +283,8 @@ export function EventBrandingProvider({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const resolved = useMemo(() => resolveBranding(branding), [branding]);
|
||||
const { appearance } = useAppearance();
|
||||
const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -211,7 +292,7 @@ export function EventBrandingProvider({
|
||||
}
|
||||
applyCssVariables(resolved);
|
||||
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
|
||||
applyThemeMode(resolved.mode ?? 'auto');
|
||||
applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride);
|
||||
|
||||
return () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -224,9 +305,9 @@ export function EventBrandingProvider({
|
||||
}
|
||||
resetCssVariables();
|
||||
applyCssVariables(DEFAULT_EVENT_BRANDING);
|
||||
applyThemeMode(DEFAULT_EVENT_BRANDING.mode ?? 'auto');
|
||||
applyThemeMode(DEFAULT_EVENT_BRANDING.mode ?? 'auto', DEFAULT_EVENT_BRANDING.backgroundColor, appearanceOverride);
|
||||
};
|
||||
}, [resolved]);
|
||||
}, [appearanceOverride, resolved]);
|
||||
|
||||
const value = useMemo<EventBrandingContextValue>(() => ({
|
||||
branding: resolved,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { EventBrandingProvider } from '../EventBrandingContext';
|
||||
import { AppearanceProvider } from '@/hooks/use-appearance';
|
||||
import type { EventBranding } from '../../types/event-branding';
|
||||
|
||||
const sampleBranding: EventBranding = {
|
||||
@@ -23,6 +24,7 @@ describe('EventBrandingProvider', () => {
|
||||
document.documentElement.style.removeProperty('color-scheme');
|
||||
document.documentElement.style.removeProperty('--guest-background');
|
||||
document.documentElement.style.removeProperty('--guest-font-scale');
|
||||
localStorage.removeItem('theme');
|
||||
});
|
||||
|
||||
it('applies guest theme classes and variables', async () => {
|
||||
@@ -44,4 +46,27 @@ describe('EventBrandingProvider', () => {
|
||||
|
||||
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
|
||||
});
|
||||
|
||||
it('respects appearance override in auto mode', async () => {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
const autoBranding: EventBranding = {
|
||||
...sampleBranding,
|
||||
mode: 'auto',
|
||||
backgroundColor: '#fff7ed',
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<AppearanceProvider>
|
||||
<EventBrandingProvider branding={autoBranding}>
|
||||
<div>Guest</div>
|
||||
</EventBrandingProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
description: 'Es gab sehr viele Aufrufe in kurzer Zeit. Warte kurz und versuche es erneut.',
|
||||
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
|
||||
},
|
||||
guest_limit_exceeded: {
|
||||
title: 'Gäste-Limit erreicht',
|
||||
description: 'Dieses Event hat sein Gäste-Limit erreicht. Bitte wende dich an die Veranstalter:innen.',
|
||||
},
|
||||
gallery_expired: {
|
||||
title: 'Galerie nicht mehr verfügbar',
|
||||
description: 'Die Galerie zu diesem Event ist nicht mehr zugänglich.',
|
||||
@@ -846,6 +850,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
description: 'There were too many requests in a short time. Please wait a moment and try again.',
|
||||
hint: 'Tip: You can retry shortly.',
|
||||
},
|
||||
guest_limit_exceeded: {
|
||||
title: 'Guest limit reached',
|
||||
description: 'This event has reached its guest allowance. Please contact the organisers.',
|
||||
},
|
||||
gallery_expired: {
|
||||
title: 'Gallery unavailable',
|
||||
description: 'The gallery for this event is no longer accessible.',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import '../../css/app.css';
|
||||
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
|
||||
import { Sentry, initSentry } from '@/lib/sentry';
|
||||
import { AppearanceProvider, initializeTheme } from '@/hooks/use-appearance';
|
||||
|
||||
const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||
@@ -11,6 +12,7 @@ const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
|
||||
);
|
||||
|
||||
initSentry('guest');
|
||||
initializeTheme();
|
||||
if (shouldEnableGuestDemoMode()) {
|
||||
enableGuestDemoMode();
|
||||
}
|
||||
@@ -47,15 +49,17 @@ const appRoot = async () => {
|
||||
createRoot(rootEl).render(
|
||||
<Sentry.ErrorBoundary fallback={<GuestFallback message="Erlebnisse können nicht geladen werden." />}>
|
||||
<React.StrictMode>
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<MatomoTracker config={matomoConfig} />
|
||||
<PwaManager />
|
||||
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>
|
||||
<AppearanceProvider>
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<MatomoTracker config={matomoConfig} />
|
||||
<PwaManager />
|
||||
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>
|
||||
</AppearanceProvider>
|
||||
</React.StrictMode>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, forma
|
||||
) : (
|
||||
<ol className="space-y-2 text-sm">
|
||||
{entries.map((entry, index) => (
|
||||
<li key={`${entry.guest}-${index}`} className="flex items-center justify-between rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
|
||||
<li key={`${entry.guest}-${index}`} className="flex items-center justify-between rounded-lg border border-border/60 bg-card/70 px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground">#{index + 1}</span>
|
||||
<span className="font-medium text-foreground">{entry.guest || t('achievements.leaderboard.guestFallback')}</span>
|
||||
@@ -129,7 +129,7 @@ export function BadgesGrid({ badges, t }: BadgesGridProps) {
|
||||
'relative overflow-hidden rounded-2xl border px-4 py-3 shadow-sm transition',
|
||||
badge.earned
|
||||
? 'border-emerald-400/40 bg-gradient-to-br from-emerald-500/20 via-emerald-500/5 to-white text-emerald-900 dark:border-emerald-400/30 dark:from-emerald-400/20 dark:via-emerald-400/10 dark:to-slate-950/70 dark:text-emerald-50'
|
||||
: 'border-border/60 bg-white/80 dark:border-slate-800/70 dark:bg-slate-950/60',
|
||||
: 'border-border/60 bg-card/90',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -165,7 +165,7 @@ function Timeline({ points, t, formatNumber }: TimelineProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{points.map((point) => (
|
||||
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
|
||||
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/60 bg-card/70 px-3 py-2">
|
||||
<span className="font-medium text-foreground">{point.date}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('achievements.timeline.row', { photos: formatNumber(point.photos), guests: formatNumber(point.guests) })}
|
||||
@@ -210,7 +210,7 @@ function Feed({ feed, t, formatRelativeTime, locale, formatNumber }: FeedProps)
|
||||
{feed.map((item) => {
|
||||
const taskLabel = localizeTaskLabel(item.task ?? null, locale);
|
||||
return (
|
||||
<div key={item.photoId} className="flex items-center gap-3 rounded-lg border border-border/40 bg-muted/20 p-3">
|
||||
<div key={item.photoId} className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/70 p-3">
|
||||
{item.thumbnail ? (
|
||||
<img src={item.thumbnail} alt={t('achievements.feed.thumbnailAlt')} className="h-16 w-16 rounded-md object-cover" />
|
||||
) : (
|
||||
|
||||
@@ -34,11 +34,13 @@ import {
|
||||
ZapOff,
|
||||
} from 'lucide-react';
|
||||
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
||||
import { isGuestDemoModeEnabled } from '../demo/demoMode';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
|
||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import DemoReadOnlyNotice from '../components/DemoReadOnlyNotice';
|
||||
import { compressPhoto, formatBytes } from '../lib/image';
|
||||
import { FADE_SCALE, FADE_UP, prefersReducedMotion } from '../lib/motion';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
@@ -149,7 +151,8 @@ export default function UploadPage() {
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||
const uploadsRequireApproval =
|
||||
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
|
||||
const demoReadOnly = Boolean(event?.demo_read_only);
|
||||
const demoModeActive = isGuestDemoModeEnabled();
|
||||
const demoReadOnly = Boolean(event?.demo_read_only) || demoModeActive;
|
||||
const liveShowModeration = event?.live_show?.moderation_mode ?? 'manual';
|
||||
const motionEnabled = !prefersReducedMotion();
|
||||
const overlayMotion = motionEnabled ? { initial: 'hidden', animate: 'show', variants: FADE_SCALE } : {};
|
||||
@@ -283,6 +286,10 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
if (!eventKey) return '/tasks';
|
||||
return `/e/${encodeURIComponent(eventKey)}/tasks`;
|
||||
}, [eventKey]);
|
||||
const demoGalleryUrl = useMemo(() => {
|
||||
if (!eventKey) return '/gallery';
|
||||
return `/e/${encodeURIComponent(eventKey)}/gallery`;
|
||||
}, [eventKey]);
|
||||
|
||||
// Load preferences from storage
|
||||
useEffect(() => {
|
||||
@@ -406,8 +413,8 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
|
||||
const checkLimits = async () => {
|
||||
if (demoReadOnly) {
|
||||
setCanUpload(false);
|
||||
setUploadError(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'));
|
||||
setCanUpload(true);
|
||||
setUploadError(null);
|
||||
setUploadWarning(null);
|
||||
return;
|
||||
}
|
||||
@@ -491,6 +498,12 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
);
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
if (demoReadOnly) {
|
||||
setPermissionState('idle');
|
||||
setPermissionMessage(null);
|
||||
stopStream();
|
||||
return;
|
||||
}
|
||||
if (!supportsCamera) {
|
||||
setPermissionState('unsupported');
|
||||
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
||||
@@ -530,7 +543,7 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
setPermissionMessage(t('upload.cameraError.explanation'));
|
||||
}
|
||||
}
|
||||
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]);
|
||||
}, [attachStreamToVideo, createConstraint, demoReadOnly, mode, preferences.facingMode, stopStream, supportsCamera, t]);
|
||||
|
||||
const handleRecheckCamera = useCallback(() => {
|
||||
if (isCameraBlockedByPolicy()) {
|
||||
@@ -741,7 +754,13 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
);
|
||||
|
||||
const handleUsePhoto = useCallback(async () => {
|
||||
if (!eventKey || !reviewPhoto || !canUpload) return;
|
||||
if (!eventKey || !reviewPhoto) return;
|
||||
if (demoReadOnly) {
|
||||
setUploadWarning(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'));
|
||||
setUploadError(null);
|
||||
return;
|
||||
}
|
||||
if (!canUpload) return;
|
||||
setMode('uploading');
|
||||
setUploadProgress(2);
|
||||
setUploadError(null);
|
||||
@@ -849,9 +868,14 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
} finally {
|
||||
setStatusMessage('');
|
||||
}
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti, submitToLive]);
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti, submitToLive, demoReadOnly]);
|
||||
|
||||
const handleGalleryPick = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (demoReadOnly) {
|
||||
setUploadWarning(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'));
|
||||
setUploadError(null);
|
||||
return;
|
||||
}
|
||||
if (!canUpload) return;
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -892,7 +916,7 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
setMode('review');
|
||||
setStatusMessage('');
|
||||
event.target.value = '';
|
||||
}, [canUpload, t]);
|
||||
}, [canUpload, t, demoReadOnly]);
|
||||
|
||||
const emotionLabel = useMemo(() => {
|
||||
if (task?.emotion?.name) return task.emotion.name;
|
||||
@@ -914,7 +938,7 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
}
|
||||
}, [task]);
|
||||
|
||||
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
|
||||
const isCameraActive = !demoReadOnly && permissionState === 'granted' && mode !== 'uploading';
|
||||
const showTaskOverlay = task && mode !== 'uploading';
|
||||
|
||||
const relativeLastUpload = useMemo(
|
||||
@@ -964,12 +988,15 @@ const [submitToLive, setSubmitToLive] = useState(true);
|
||||
|
||||
const handlePrimaryAction = useCallback(() => {
|
||||
setShowHeroOverlay(false);
|
||||
if (demoReadOnly) {
|
||||
return;
|
||||
}
|
||||
if (!isCameraActive) {
|
||||
startCamera();
|
||||
return;
|
||||
}
|
||||
beginCapture();
|
||||
}, [beginCapture, isCameraActive, startCamera]);
|
||||
}, [beginCapture, demoReadOnly, isCameraActive, startCamera]);
|
||||
|
||||
const taskFloatingCard = showTaskOverlay && task ? (
|
||||
<motion.button
|
||||
@@ -1084,9 +1111,10 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t('upload.limitReached')
|
||||
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
||||
.replace('{max}', `${eventPackage?.package?.max_photos || 0}`)}
|
||||
{uploadError
|
||||
?? t('upload.limitReached')
|
||||
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
||||
.replace('{max}', `${eventPackage?.package?.max_photos || 0}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
@@ -1116,7 +1144,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
);
|
||||
|
||||
const renderPermissionNotice = () => {
|
||||
if (permissionState === 'granted') return null;
|
||||
if (demoReadOnly || permissionState === 'granted') return null;
|
||||
|
||||
const titles: Record<PermissionState, string> = {
|
||||
idle: t('upload.cameraDenied.title'),
|
||||
@@ -1233,12 +1261,27 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
maxHeight: '88vh',
|
||||
}}
|
||||
>
|
||||
{demoReadOnly && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-800" />
|
||||
<div className="absolute inset-0 opacity-40 [background-image:linear-gradient(120deg,rgba(255,255,255,0.12),transparent_45%),radial-gradient(circle_at_15%_20%,rgba(255,255,255,0.18),transparent_38%)]" />
|
||||
<div className="absolute inset-6 rounded-[32px] border border-white/10" />
|
||||
<div className="absolute bottom-8 left-1/2 flex -translate-x-1/2 items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full border border-white/25 bg-white/10" />
|
||||
<div className="h-16 w-16 rounded-full border-2 border-white/35 bg-white/10" />
|
||||
<div className="h-10 w-10 rounded-full border border-white/25 bg-white/10" />
|
||||
</div>
|
||||
<div className="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-white/80">
|
||||
<span>{t('upload.demoReadOnly.label', 'Demo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={cn(
|
||||
'absolute inset-0 h-full w-full object-cover transition-transform duration-200',
|
||||
preferences.facingMode === 'user' && preferences.mirrorFrontPreview ? '-scale-x-100' : 'scale-x-100',
|
||||
!isCameraActive && 'opacity-30'
|
||||
demoReadOnly ? 'opacity-0' : !isCameraActive && 'opacity-30'
|
||||
)}
|
||||
playsInline
|
||||
muted
|
||||
@@ -1255,7 +1298,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCameraActive && (
|
||||
{!isCameraActive && !demoReadOnly && (
|
||||
<div className="absolute left-4 top-4 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
|
||||
<Camera className="h-4 w-4 text-pink-400" />
|
||||
<span>
|
||||
@@ -1268,8 +1311,23 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permissionState !== 'granted' && (
|
||||
{(demoReadOnly || permissionState !== 'granted') && (
|
||||
<div className="absolute inset-x-4 top-16 z-30 sm:top-20">
|
||||
{demoReadOnly ? (
|
||||
<DemoReadOnlyNotice
|
||||
title={t('upload.demoReadOnly.title', 'Demo-Modus aktiv')}
|
||||
copy={t(
|
||||
'upload.demoReadOnly.copy',
|
||||
'Aus Datenschutzgründen zeigen wir hier nur eine Vorschau. Im echten Event erscheint der Live-Kamera-Feed und du kannst Fotos hochladen.'
|
||||
)}
|
||||
hint={t('upload.demoReadOnly.hint', 'Du kannst die Oberfläche trotzdem erkunden.')}
|
||||
ctaLabel={t('upload.demoReadOnly.cta', 'Zur Demo-Galerie')}
|
||||
onCta={() => navigate(demoGalleryUrl)}
|
||||
radius={radius}
|
||||
bodyFont={bodyFont}
|
||||
motionProps={fadeUpMotion}
|
||||
/>
|
||||
) : null}
|
||||
{renderPermissionNotice()}
|
||||
</div>
|
||||
)}
|
||||
@@ -1442,6 +1500,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
variant="ghost"
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur hover:border-white/40 hover:bg-white/15"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={demoReadOnly}
|
||||
>
|
||||
<ImagePlus className="h-6 w-6" />
|
||||
<span className="sr-only">{t('upload.galleryButton')}</span>
|
||||
@@ -1505,7 +1564,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
size="lg"
|
||||
className="relative z-10 flex h-20 w-20 items-center justify-center rounded-full border-4 border-white/40 text-white shadow-2xl"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={mode === 'uploading' || isCountdownActive}
|
||||
disabled={demoReadOnly || mode === 'uploading' || isCountdownActive}
|
||||
style={{
|
||||
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
|
||||
boxShadow: `0 18px 36px ${branding.primaryColor}55`,
|
||||
@@ -1529,6 +1588,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
variant="ghost"
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur hover:border-white/40 hover:bg-white/15"
|
||||
onClick={handleSwitchCamera}
|
||||
disabled={demoReadOnly}
|
||||
>
|
||||
<RotateCcw className="h-6 w-6" />
|
||||
<span className="sr-only">{t('upload.switchCamera')}</span>
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('BadgesGrid', () => {
|
||||
expect(earnedCard.className).toContain('dark:text-emerald-50');
|
||||
|
||||
const pendingCard = screen.getByTestId('badge-card-2');
|
||||
expect(pendingCard.className).toContain('dark:bg-slate-950/60');
|
||||
expect(pendingCard.className).toContain('dark:border-slate-800/70');
|
||||
expect(pendingCard.className).toContain('bg-card/90');
|
||||
expect(pendingCard.className).toContain('border-border/60');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import UploadPage from '../UploadPage';
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ token: 'demo' }),
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock('../../demo/demoMode', () => ({
|
||||
isGuestDemoModeEnabled: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGuestTaskProgress', () => ({
|
||||
useGuestTaskProgress: () => ({
|
||||
markCompleted: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/GuestIdentityContext', () => ({
|
||||
useGuestIdentity: () => ({
|
||||
name: 'Guest',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useEventData', () => ({
|
||||
useEventData: () => ({
|
||||
event: {
|
||||
guest_upload_visibility: 'immediate',
|
||||
demo_read_only: false,
|
||||
engagement_mode: 'photo_only',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventStatsContext', () => ({
|
||||
useEventStats: () => ({
|
||||
latestPhotoAt: null,
|
||||
onlineGuests: 2,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventBrandingContext', () => ({
|
||||
useEventBranding: () => ({
|
||||
branding: {
|
||||
primaryColor: '#FF5A5F',
|
||||
secondaryColor: '#FFF8F5',
|
||||
buttons: { radius: 12 },
|
||||
typography: {},
|
||||
fontFamily: 'Montserrat',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
locale: 'de',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/eventApi', () => ({
|
||||
getEventPackage: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/photosApi', () => ({
|
||||
uploadPhoto: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('UploadPage demo mode', () => {
|
||||
it('keeps the UI visible and shows the demo notice', async () => {
|
||||
render(<UploadPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Demo-Modus aktiv')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
23
resources/js/guest/services/__tests__/eventApi.test.ts
Normal file
23
resources/js/guest/services/__tests__/eventApi.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fetchEvent, FetchEventError } from '../eventApi';
|
||||
|
||||
describe('fetchEvent', () => {
|
||||
it('maps guest_limit_exceeded error codes', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 402,
|
||||
json: async () => ({
|
||||
error: { code: 'guest_limit_exceeded', message: 'Limit reached' },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEvent('token')).rejects.toEqual(
|
||||
expect.objectContaining<Pick<FetchEventError, 'code'>>({
|
||||
code: 'guest_limit_exceeded',
|
||||
})
|
||||
);
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,7 @@ export type FetchEventErrorCode =
|
||||
| 'token_revoked'
|
||||
| 'token_rate_limited'
|
||||
| 'access_rate_limited'
|
||||
| 'guest_limit_exceeded'
|
||||
| 'gallery_expired'
|
||||
| 'event_not_public'
|
||||
| 'network_error'
|
||||
@@ -187,6 +188,7 @@ const API_ERROR_CODES: FetchEventErrorCode[] = [
|
||||
'token_revoked',
|
||||
'token_rate_limited',
|
||||
'access_rate_limited',
|
||||
'guest_limit_exceeded',
|
||||
'gallery_expired',
|
||||
'event_not_public',
|
||||
];
|
||||
@@ -221,6 +223,8 @@ function defaultMessageForCode(code: FetchEventErrorCode): string {
|
||||
return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
|
||||
case 'access_rate_limited':
|
||||
return 'Zu viele Aufrufe in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
|
||||
case 'guest_limit_exceeded':
|
||||
return 'Dieses Event hat sein Gäste-Limit erreicht. Bitte kontaktiere die Veranstalter:innen.';
|
||||
case 'gallery_expired':
|
||||
return 'Die Galerie ist nicht mehr verfügbar.';
|
||||
case 'event_not_public':
|
||||
@@ -237,7 +241,11 @@ function defaultMessageForCode(code: FetchEventErrorCode): string {
|
||||
|
||||
export async function fetchEvent(eventKey: string): Promise<EventData> {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`, {
|
||||
headers: {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
let apiMessage: string | null = null;
|
||||
let rawCode: unknown;
|
||||
|
||||
@@ -39,6 +39,10 @@ return [
|
||||
'title' => 'Foto-Limit erreicht',
|
||||
'message' => 'Dieses Event hat sein Foto-Kontingent erreicht. Upgrade das Event-Paket, um weitere Uploads zu erlauben.',
|
||||
],
|
||||
'guest_limit_exceeded' => [
|
||||
'title' => 'Gäste-Limit erreicht',
|
||||
'message' => 'Dieses Event hat sein Gäste-Kontingent erreicht. Bitte wende dich an die Veranstalter, um weitere Gäste freizuschalten.',
|
||||
],
|
||||
'tenant_photo_limit_exceeded' => [
|
||||
'title' => 'Tenant-Foto-Limit erreicht',
|
||||
'message' => 'Dieser Tenant hat das Foto-Kontingent für dieses Event erreicht.',
|
||||
|
||||
@@ -39,6 +39,10 @@ return [
|
||||
'title' => 'Photo upload limit reached',
|
||||
'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.',
|
||||
],
|
||||
'guest_limit_exceeded' => [
|
||||
'title' => 'Guest limit reached',
|
||||
'message' => 'This event has reached its guest allowance. Please contact the organiser to unlock more guest slots.',
|
||||
],
|
||||
'tenant_photo_limit_exceeded' => [
|
||||
'title' => 'Tenant photo limit reached',
|
||||
'message' => 'This tenant has reached its photo allowance for the event.',
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Enums\PhotoLiveStatus;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\GuestPolicySetting;
|
||||
use App\Models\MediaStorageTarget;
|
||||
@@ -223,6 +224,44 @@ class GuestJoinTokenFlowTest extends TestCase
|
||||
->assertJsonPath('demo_read_only', true);
|
||||
}
|
||||
|
||||
public function test_guest_limit_counts_unique_devices(): void
|
||||
{
|
||||
$event = $this->createPublishedEvent();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'max_guests' => 2,
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$token = $this->tokenService->createToken($event);
|
||||
|
||||
$this->withHeader('X-Device-Id', 'device-guest')
|
||||
->getJson("/api/v1/events/{$token->token}")
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame(1, $eventPackage->refresh()->used_guests);
|
||||
|
||||
$this->withHeader('X-Device-Id', 'device-guest')
|
||||
->getJson("/api/v1/events/{$token->token}")
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame(1, $eventPackage->refresh()->used_guests);
|
||||
|
||||
$this->withHeader('X-Device-Id', 'device-guest-2')
|
||||
->getJson("/api/v1/events/{$token->token}")
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame(2, $eventPackage->refresh()->used_guests);
|
||||
}
|
||||
|
||||
public function test_guest_event_response_includes_live_show_settings(): void
|
||||
{
|
||||
$event = $this->createPublishedEvent();
|
||||
@@ -241,6 +280,44 @@ class GuestJoinTokenFlowTest extends TestCase
|
||||
->assertJsonPath('live_show.moderation_mode', 'manual');
|
||||
}
|
||||
|
||||
public function test_guest_join_blocks_new_devices_after_grace_limit(): void
|
||||
{
|
||||
$event = $this->createPublishedEvent();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'max_guests' => 1,
|
||||
]);
|
||||
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 11,
|
||||
'gallery_expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$token = $this->tokenService->createToken($event);
|
||||
|
||||
$this->withHeader('X-Device-Id', 'new-device')
|
||||
->getJson("/api/v1/events/{$token->token}")
|
||||
->assertStatus(402)
|
||||
->assertJsonPath('error.code', 'guest_limit_exceeded');
|
||||
|
||||
EventJoinTokenEvent::create([
|
||||
'event_join_token_id' => $token->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_type' => 'access_granted',
|
||||
'device_id' => 'known-device',
|
||||
'occurred_at' => now(),
|
||||
]);
|
||||
|
||||
$this->withHeader('X-Device-Id', 'known-device')
|
||||
->getJson("/api/v1/events/{$token->token}")
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_guest_cannot_upload_photo_with_demo_token(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
@@ -161,6 +161,36 @@ class PackageLimitEvaluatorTest extends TestCase
|
||||
$this->assertTrue($summary['can_add_guests']);
|
||||
}
|
||||
|
||||
public function test_guest_limits_apply_grace_buffer_before_blocking(): void
|
||||
{
|
||||
Config::set('package-limits.guest_grace', 10);
|
||||
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'max_guests' => 5,
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$event = Event::factory()->for($tenant)->create();
|
||||
|
||||
$eventPackage = EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 5,
|
||||
'gallery_expires_at' => now()->addDays(7),
|
||||
])->fresh(['package']);
|
||||
|
||||
$summary = $this->evaluator->summarizeEventPackage($eventPackage);
|
||||
$this->assertSame('warning', $summary['guests']['state']);
|
||||
|
||||
$eventPackage->update(['used_guests' => 15]);
|
||||
|
||||
$summary = $this->evaluator->summarizeEventPackage($eventPackage->fresh(['package']));
|
||||
$this->assertSame('limit_reached', $summary['guests']['state']);
|
||||
}
|
||||
|
||||
public function test_assess_photo_upload_respects_extra_limits(): void
|
||||
{
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
|
||||
@@ -134,14 +134,14 @@ class PackageUsageTrackerTest extends TestCase
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 2,
|
||||
'used_guests' => 12,
|
||||
'gallery_expires_at' => now()->addDays(7),
|
||||
])->fresh(['package']);
|
||||
|
||||
/** @var PackageUsageTracker $tracker */
|
||||
$tracker = app(PackageUsageTracker::class);
|
||||
|
||||
$tracker->recordGuestUsage($eventPackage, 1, 1);
|
||||
$tracker->recordGuestUsage($eventPackage, 11, 1);
|
||||
|
||||
EventFacade::assertDispatched(EventPackageGuestLimitReached::class);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user