Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).

Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
Codex Agent
2025-11-01 19:50:17 +01:00
parent 2c14493604
commit 79b209de9a
55 changed files with 3348 additions and 462 deletions

View File

@@ -5,6 +5,14 @@ import BottomNav from '../components/BottomNav';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { uploadPhoto, type UploadError } from '../services/photosApi';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { cn } from '@/lib/utils';
@@ -22,6 +30,8 @@ import {
} from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
interface Task {
id: number;
@@ -65,19 +75,6 @@ function getErrorName(error: unknown): string | 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,
@@ -87,6 +84,24 @@ const DEFAULT_PREFS: CameraPreferences = {
flashPreferred: false,
};
const LIMIT_CARD_STYLES: Record<LimitSummaryCard['tone'], { card: string; badge: string; bar: string }> = {
neutral: {
card: 'border-slate-200 bg-white/90 text-slate-900 dark:border-white/15 dark:bg-white/10 dark:text-white',
badge: 'bg-slate-900/10 text-slate-900 dark:bg-white/20 dark:text-white',
bar: 'bg-emerald-500',
},
warning: {
card: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-50',
badge: 'bg-white/70 text-amber-900 dark:bg-amber-400/25 dark:text-amber-50',
bar: 'bg-amber-500',
},
danger: {
card: 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-400/50 dark:bg-rose-500/15 dark:text-rose-50',
badge: 'bg-white/70 text-rose-900 dark:bg-rose-400/20 dark:text-rose-50',
bar: 'bg-rose-500',
},
};
export default function UploadPage() {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
@@ -115,12 +130,19 @@ export default function UploadPage() {
const [statusMessage, setStatusMessage] = useState<string>('');
const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
const limitCards = useMemo<LimitSummaryCard[]>(
() => buildLimitSummaries(eventPackage?.limits ?? null, t),
[eventPackage?.limits, t]
);
const [showPrimer, setShowPrimer] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
@@ -249,38 +271,55 @@ export default function UploadPage() {
try {
const pkg = await getEventPackage(eventKey);
setEventPackage(pkg);
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
setCanUpload(false);
const maxLabel = pkg.package.max_photos == null
? t('upload.limitUnlimited')
: `${pkg.package.max_photos}`;
setUploadError(
t('upload.limitReached')
.replace('{used}', `${pkg.used_photos}`)
.replace('{max}', maxLabel)
);
} else {
if (!pkg) {
setCanUpload(true);
setUploadError(null);
setUploadWarning(null);
return;
}
if (pkg?.package?.max_photos) {
const max = Number(pkg.package.max_photos);
const used = Number(pkg.used_photos ?? 0);
const ratio = max > 0 ? used / max : 0;
if (ratio >= 0.8 && ratio < 1) {
const remaining = Math.max(0, max - used);
setUploadWarning(
t('upload.limitWarning')
.replace('{remaining}', `${remaining}`)
.replace('{max}', `${max}`)
);
const photoLimits = pkg.limits?.photos ?? null;
const galleryLimits = pkg.limits?.gallery ?? null;
let canUploadCurrent = pkg.limits?.can_upload_photos ?? true;
let errorMessage: string | null = null;
const warnings: string[] = [];
if (photoLimits?.state === 'limit_reached') {
canUploadCurrent = false;
if (typeof photoLimits.limit === 'number') {
errorMessage = t('upload.limitReached')
.replace('{used}', `${photoLimits.used}`)
.replace('{max}', `${photoLimits.limit}`);
} else {
setUploadWarning(null);
errorMessage = t('upload.errors.photoLimit');
}
} else {
setUploadWarning(null);
} else if (
photoLimits?.state === 'warning'
&& typeof photoLimits.remaining === 'number'
&& typeof photoLimits.limit === 'number'
) {
warnings.push(
t('upload.limitWarning')
.replace('{remaining}', `${photoLimits.remaining}`)
.replace('{max}', `${photoLimits.limit}`)
);
}
if (galleryLimits?.state === 'expired') {
canUploadCurrent = false;
errorMessage = t('upload.errors.galleryExpired');
} else if (galleryLimits?.state === 'warning') {
const daysLeft = Math.max(0, galleryLimits.days_remaining ?? 0);
const key = daysLeft === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays';
warnings.push(
t(key).replace('{days}', `${daysLeft}`)
);
}
setCanUpload(canUploadCurrent);
setUploadError(errorMessage);
setUploadWarning(errorMessage ? null : (warnings.length > 0 ? warnings.join(' · ') : null));
} catch (err) {
console.error('Failed to check package limits', err);
setCanUpload(false);
@@ -543,39 +582,20 @@ export default function UploadPage() {
const uploadErr = error as UploadError;
setUploadWarning(null);
const meta = uploadErr.meta as Record<string, unknown> | undefined;
switch (uploadErr.code) {
case 'photo_limit_exceeded': {
if (meta && typeof meta.used === 'number' && typeof meta.limit === 'number') {
const limitText = t('upload.limitReached')
.replace('{used}', `${meta.used}`)
.replace('{max}', `${meta.limit}`);
setUploadError(limitText);
} else {
setUploadError(t('upload.errors.photoLimit'));
}
setCanUpload(false);
break;
}
case 'upload_device_limit': {
setUploadError(t('upload.errors.deviceLimit'));
setCanUpload(false);
break;
}
case 'event_package_missing':
case 'event_not_found': {
setUploadError(t('upload.errors.packageMissing'));
setCanUpload(false);
break;
}
case 'gallery_expired': {
setUploadError(t('upload.errors.galleryExpired'));
setCanUpload(false);
break;
}
default: {
setUploadError(getErrorMessage(uploadErr) || t('upload.errors.generic'));
}
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, t);
setErrorDialog(dialog);
setUploadError(dialog.description);
if (
uploadErr.code === 'photo_limit_exceeded'
|| uploadErr.code === 'upload_device_limit'
|| uploadErr.code === 'event_package_missing'
|| uploadErr.code === 'event_not_found'
|| uploadErr.code === 'gallery_expired'
) {
setCanUpload(false);
}
setMode('review');
} finally {
if (uploadProgressTimerRef.current) {
@@ -625,6 +645,54 @@ export default function UploadPage() {
}
}, [resetCountdownTimer]);
const limitStatusSection = limitCards.length > 0 ? (
<section className="mx-4 mb-6 space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-900 dark:text-white">
{t('upload.status.title')}
</h2>
<p className="text-xs text-slate-600 dark:text-white/70">
{t('upload.status.subtitle')}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{limitCards.map((card) => {
const styles = LIMIT_CARD_STYLES[card.tone];
return (
<div
key={card.id}
className={cn(
'rounded-xl border p-4 shadow-sm backdrop-blur transition-colors',
styles.card
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide opacity-70">
{card.label}
</p>
<p className="text-lg font-semibold">{card.valueLabel}</p>
</div>
<Badge className={cn('text-[10px] font-semibold uppercase tracking-wide', styles.badge)}>
{card.badgeLabel}
</Badge>
</div>
{card.progress !== null && (
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/60 dark:bg-white/10">
<div
className={cn('h-full rounded-full transition-all', styles.bar)}
style={{ width: `${card.progress}%` }}
/>
</div>
)}
<p className="mt-3 text-sm opacity-80">{card.description}</p>
</div>
);
})}
</div>
</section>
) : null;
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
@@ -633,16 +701,56 @@ export default function UploadPage() {
</div>
);
const dialogToneIconClass: Record<Exclude<UploadErrorDialog['tone'], undefined>, string> = {
danger: 'text-rose-500',
warning: 'text-amber-500',
info: 'text-sky-500',
};
const errorDialogNode = (
<Dialog open={Boolean(errorDialog)} onOpenChange={(open) => { if (!open) setErrorDialog(null); }}>
<DialogContent>
<DialogHeader className="space-y-3">
<div className="flex items-center gap-3">
{errorDialog?.tone === 'info' ? (
<Info className={cn('h-5 w-5', dialogToneIconClass.info)} />
) : (
<AlertTriangle className={cn('h-5 w-5', dialogToneIconClass[errorDialog?.tone ?? 'danger'])} />
)}
<DialogTitle>{errorDialog?.title ?? ''}</DialogTitle>
</div>
<DialogDescription>{errorDialog?.description ?? ''}</DialogDescription>
{errorDialog?.hint ? (
<p className="text-sm text-muted-foreground">{errorDialog.hint}</p>
) : null}
</DialogHeader>
<DialogFooter>
<Button onClick={() => setErrorDialog(null)}>{t('upload.dialogs.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
const renderWithDialog = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<>
{renderPage(content, mainClassName)}
{errorDialogNode}
</>
);
if (!supportsCamera && !task) {
return renderPage(
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
return renderWithDialog(
<>
{limitStatusSection}
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</>
);
}
if (loadingTask) {
return renderPage(
return renderWithDialog(
<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>
@@ -651,15 +759,18 @@ export default function UploadPage() {
}
if (!canUpload) {
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>
return renderWithDialog(
<>
{limitStatusSection}
<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>
</>
);
}
@@ -711,13 +822,14 @@ export default function UploadPage() {
);
};
return renderPage(
return renderWithDialog(
<>
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}
</div>
<div className="pt-32" />
{permissionState !== 'granted' && renderPermissionNotice()}
{limitStatusSection}
<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">