Fix guest demo UX and enforce guest limits

This commit is contained in:
Codex Agent
2026-01-21 21:35:40 +01:00
parent a01a7ec399
commit 80dd12bb92
28 changed files with 812 additions and 118 deletions

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

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