Fix guest demo UX and enforce guest limits
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user