Fix guest demo UX and enforce guest limits
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-21 21:35:40 +01:00
parent 50cc4e76df
commit 2f9a700e00
28 changed files with 812 additions and 118 deletions

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -233,10 +233,13 @@ class PackageLimitEvaluator
config('package-limits.photo_thresholds', [])
);
$guestSummary = $this->buildUsageSummary(
$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;
}
}

View File

@@ -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));
}
}
}

View File

@@ -9,6 +9,7 @@ return [
0.8,
0.95,
],
'guest_grace' => 10,
'gallery_warning_days' => [
7,
1,

View File

@@ -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,

View File

@@ -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,13 +144,7 @@ class DemoPhotosSeeder extends Seeder
$taskId = $taskIds ? $taskIds[array_rand($taskIds)] : null;
$emotionId = $emotions->random()->id;
$photo = Photo::updateOrCreate(
[
'tenant_id' => $tenant->id,
'event_id' => $event->id,
'file_path' => $destPath,
],
[
$photoData = [
'task_id' => $taskId,
'emotion_id' => $emotionId,
'guest_name' => $guestName,
@@ -159,7 +154,19 @@ class DemoPhotosSeeder extends Seeder
'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,
],
$photoData
);
PhotoLike::where('photo_id', $photo->id)->delete();

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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', () => {

View File

@@ -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;

View 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>
);
}

View File

@@ -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 />

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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.',

View File

@@ -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,6 +49,7 @@ const appRoot = async () => {
createRoot(rootEl).render(
<Sentry.ErrorBoundary fallback={<GuestFallback message="Erlebnisse können nicht geladen werden." />}>
<React.StrictMode>
<AppearanceProvider>
<LocaleProvider>
<ToastProvider>
<MatomoTracker config={matomoConfig} />
@@ -56,6 +59,7 @@ const appRoot = async () => {
</Suspense>
</ToastProvider>
</LocaleProvider>
</AppearanceProvider>
</React.StrictMode>
</Sentry.ErrorBoundary>
);

View File

@@ -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" />
) : (

View File

@@ -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,7 +1111,8 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('upload.limitReached')
{uploadError
?? t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package?.max_photos || 0}`)}
</AlertDescription>
@@ -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>

View File

@@ -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');
});
});

View File

@@ -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();
});
});
});

View 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;
});
});

View File

@@ -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;

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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');

View File

@@ -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([

View File

@@ -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);
}