Implement package limit notification system
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user