Implement package limit notification system

This commit is contained in:
Codex Agent
2025-11-01 13:19:07 +01:00
parent 81cdee428e
commit 2c14493604
87 changed files with 4557 additions and 290 deletions

View File

@@ -297,6 +297,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
limitUnlimited: 'unbegrenzt',
limitWarning: 'Nur noch {remaining} von {max} Fotos möglich. Bitte kontaktiere die Veranstalter für ein Upgrade.',
errors: {
photoLimit: 'Upload-Limit erreicht. Bitte kontaktiere die Veranstalter für ein Upgrade.',
deviceLimit: 'Dieses Gerät hat das Upload-Limit erreicht. Bitte wende dich an die Veranstalter.',
packageMissing: 'Dieses Event akzeptiert derzeit keine Uploads.',
galleryExpired: 'Die Galerie ist abgelaufen. Uploads sind nicht mehr möglich.',
generic: 'Upload fehlgeschlagen. Bitte versuche es erneut.',
},
cameraInactive: 'Kamera ist nicht aktiv. {hint}',
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
captureError: 'Foto konnte nicht erstellt werden.',
@@ -652,6 +660,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.',
limitUnlimited: 'unlimited',
limitWarning: 'Only {remaining} of {max} photos left. Please contact the organizers for an upgrade.',
errors: {
photoLimit: 'Upload limit reached. Contact the organizers for an upgrade.',
deviceLimit: 'This device reached its upload limit. Please contact the organizers.',
packageMissing: 'This event is not accepting uploads right now.',
galleryExpired: 'The gallery has expired. Uploads are no longer possible.',
generic: 'Upload failed. Please try again.',
},
cameraInactive: 'Camera is not active. {hint}',
cameraInactiveHint: 'Tap "{label}" to get started.',
captureError: 'Photo could not be created.',

View File

@@ -5,7 +5,7 @@ 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 { uploadPhoto } from '../services/photosApi';
import { uploadPhoto, type UploadError } from '../services/photosApi';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { cn } from '@/lib/utils';
import {
@@ -117,6 +117,7 @@ export default function UploadPage() {
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 [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
@@ -262,10 +263,29 @@ export default function UploadPage() {
setCanUpload(true);
setUploadError(null);
}
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}`)
);
} else {
setUploadWarning(null);
}
} else {
setUploadWarning(null);
}
} catch (err) {
console.error('Failed to check package limits', err);
setCanUpload(false);
setUploadError(t('upload.limitCheckError'));
setUploadWarning(null);
}
};
@@ -520,7 +540,42 @@ export default function UploadPage() {
navigateAfterUpload(photoId);
} catch (error: unknown) {
console.error('Upload failed', error);
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
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'));
}
}
setMode('review');
} finally {
if (uploadProgressTimerRef.current) {
@@ -773,6 +828,13 @@ export default function UploadPage() {
</div>
<div className="relative z-30 flex flex-col gap-3 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
{uploadWarning && (
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
<AlertDescription className="text-xs">
{uploadWarning}
</AlertDescription>
</Alert>
)}
{uploadError && (
<Alert variant="destructive" className="bg-red-500/10 text-white">
<AlertDescription className="flex items-center gap-2 text-xs">

View File

@@ -1,5 +1,11 @@
import { getDeviceId } from '../lib/device';
export type UploadError = Error & {
code?: string;
status?: number;
meta?: Record<string, unknown>;
};
function getCsrfToken(): string | null {
// Method 1: Meta tag (preferred for SPA)
const metaToken = document.querySelector('meta[name="csrf-token"]');
@@ -56,16 +62,30 @@ export async function likePhoto(id: number): Promise<number> {
});
if (!res.ok) {
const errorText = await res.text();
let payload: any = null;
try {
payload = await res.clone().json();
} catch {}
if (res.status === 419) {
throw new Error('CSRF Token mismatch. This usually means:\n\n' +
'1. The page needs to be refreshed\n' +
'2. Check if <meta name="csrf-token"> is present in HTML source\n' +
'3. API routes might need CSRF exemption in VerifyCsrfToken middleware');
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
error.code = 'csrf_mismatch';
error.status = res.status;
throw error;
}
throw new Error(`Like failed: ${res.status} - ${errorText}`);
const error: UploadError = new Error(
payload?.error?.message ?? `Like failed: ${res.status}`
);
error.code = payload?.error?.code ?? 'like_failed';
error.status = res.status;
if (payload?.error?.meta) {
error.meta = payload.error.meta as Record<string, unknown>;
}
throw error;
}
const json = await res.json();
return json.likes_count ?? json.data?.likes_count ?? 0;
}
@@ -85,15 +105,30 @@ export async function uploadPhoto(eventToken: string, file: File, taskId?: numbe
});
if (!res.ok) {
const errorText = await res.text();
let payload: any = null;
try {
payload = await res.clone().json();
} catch {}
if (res.status === 419) {
throw new Error('CSRF Token mismatch during upload.\n\n' +
'This usually means:\n' +
'1. API routes need CSRF exemption in VerifyCsrfToken middleware\n' +
'2. Check if <meta name="csrf-token"> is present in page source\n' +
'3. The page might need to be refreshed');
const csrfError: UploadError = new Error(
'CSRF token mismatch during upload. Please refresh the page and try again.'
);
csrfError.code = 'csrf_mismatch';
csrfError.status = res.status;
throw csrfError;
}
throw new Error(`Upload failed: ${res.status} - ${errorText}`);
const error: UploadError = new Error(
payload?.error?.message ?? `Upload failed: ${res.status}`
);
error.code = payload?.error?.code ?? 'upload_failed';
error.status = res.status;
if (payload?.error?.meta) {
error.meta = payload.error.meta as Record<string, unknown>;
}
throw error;
}
const json = await res.json();