feat: extend event toolkit and polish guest pwa
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import Header from '../components/Header';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { uploadPhoto } from '../services/photosApi';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { useAppearance } from '../../hooks/use-appearance';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -46,6 +45,39 @@ type CameraPreferences = {
|
||||
flashPreferred: boolean;
|
||||
};
|
||||
|
||||
type TaskPayload = Partial<Task> & { id: number };
|
||||
|
||||
function isTaskPayload(value: unknown): value is TaskPayload {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as { id?: unknown };
|
||||
return typeof candidate.id === 'number';
|
||||
}
|
||||
|
||||
function getErrorName(error: unknown): string | undefined {
|
||||
if (typeof error === 'object' && error !== null && 'name' in error) {
|
||||
const name = (error as { name?: unknown }).name;
|
||||
return typeof name === 'string' ? name : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string | undefined {
|
||||
if (error instanceof Error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
return typeof message === 'string' ? message : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: CameraPreferences = {
|
||||
facingMode: 'environment',
|
||||
countdownSeconds: 3,
|
||||
@@ -60,8 +92,6 @@ export default function UploadPage() {
|
||||
const eventKey = token ?? '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { appearance } = useAppearance();
|
||||
const isDarkMode = appearance === 'dark';
|
||||
const { markCompleted } = useGuestTaskProgress(token);
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -75,7 +105,6 @@ export default function UploadPage() {
|
||||
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [loadingTask, setLoadingTask] = useState(true);
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
|
||||
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
|
||||
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
|
||||
@@ -138,8 +167,7 @@ export default function UploadPage() {
|
||||
|
||||
// Load task metadata
|
||||
useEffect(() => {
|
||||
if (!token || !taskId) {
|
||||
setTaskError(t('upload.loadError.title'));
|
||||
if (!token || taskId === null) {
|
||||
setLoadingTask(false);
|
||||
return;
|
||||
}
|
||||
@@ -147,18 +175,19 @@ export default function UploadPage() {
|
||||
let active = true;
|
||||
|
||||
async function loadTask() {
|
||||
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`);
|
||||
const currentTaskId = taskId;
|
||||
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`);
|
||||
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
|
||||
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
|
||||
|
||||
try {
|
||||
setLoadingTask(true);
|
||||
setTaskError(null);
|
||||
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
|
||||
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||
const tasks = await res.json();
|
||||
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
|
||||
const payload = (await res.json()) as unknown;
|
||||
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
|
||||
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
|
||||
|
||||
if (!active) return;
|
||||
|
||||
@@ -174,7 +203,7 @@ export default function UploadPage() {
|
||||
});
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId!,
|
||||
id: currentTaskId,
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
instructions: fallbackInstructions,
|
||||
@@ -188,9 +217,8 @@ export default function UploadPage() {
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task', error);
|
||||
if (active) {
|
||||
setTaskError(t('upload.loadError.title'));
|
||||
setTask({
|
||||
id: taskId!,
|
||||
id: currentTaskId,
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
instructions: fallbackInstructions,
|
||||
@@ -210,7 +238,7 @@ export default function UploadPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [eventKey, taskId, emotionSlug, t]);
|
||||
}, [eventKey, taskId, emotionSlug, t, token]);
|
||||
|
||||
// Check upload limits
|
||||
useEffect(() => {
|
||||
@@ -294,14 +322,15 @@ export default function UploadPage() {
|
||||
streamRef.current = stream;
|
||||
attachStreamToVideo(stream);
|
||||
setPermissionState('granted');
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Camera access error', error);
|
||||
stopStream();
|
||||
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
const errorName = getErrorName(error);
|
||||
if (errorName === 'NotAllowedError') {
|
||||
setPermissionState('denied');
|
||||
setPermissionMessage(t('upload.cameraDenied.explanation'));
|
||||
} else if (error?.name === 'NotFoundError') {
|
||||
} else if (errorName === 'NotFoundError') {
|
||||
setPermissionState('error');
|
||||
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
||||
} else {
|
||||
@@ -489,9 +518,9 @@ export default function UploadPage() {
|
||||
markCompleted(task.id);
|
||||
stopStream();
|
||||
navigateAfterUpload(photoId);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Upload failed', error);
|
||||
setUploadError(error?.message || t('upload.status.failed'));
|
||||
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
|
||||
setMode('review');
|
||||
} finally {
|
||||
if (uploadProgressTimerRef.current) {
|
||||
@@ -533,7 +562,6 @@ export default function UploadPage() {
|
||||
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
|
||||
const showTaskOverlay = task && mode !== 'uploading';
|
||||
|
||||
const isUploadDisabled = !canUpload || !task;
|
||||
|
||||
useEffect(() => () => {
|
||||
resetCountdownTimer();
|
||||
@@ -542,49 +570,41 @@ export default function UploadPage() {
|
||||
}
|
||||
}, [resetCountdownTimer]);
|
||||
|
||||
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className={mainClassName}>{content}</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!supportsCamera && !task) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6">
|
||||
<Alert>
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
return renderPage(
|
||||
<Alert>
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadingTask) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||
</main>
|
||||
<BottomNav />
|
||||
return renderPage(
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
|
||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUpload) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6">
|
||||
<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}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
return renderPage(
|
||||
<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}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -636,18 +656,16 @@ export default function UploadPage() {
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="relative flex flex-col gap-4 pb-4">
|
||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||
{renderPrimer()}
|
||||
</div>
|
||||
<div className="pt-32" />
|
||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
||||
return renderPage(
|
||||
<>
|
||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||
{renderPrimer()}
|
||||
</div>
|
||||
<div className="pt-32" />
|
||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
||||
|
||||
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
|
||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
|
||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={cn(
|
||||
@@ -863,9 +881,10 @@ export default function UploadPage() {
|
||||
/>
|
||||
|
||||
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
|
||||
</main>
|
||||
<BottomNav />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</>
|
||||
,
|
||||
'relative flex flex-col gap-4 pb-4'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user