Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user