Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
@@ -188,6 +188,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
title: 'Nicht gefunden',
|
||||
description: 'Die Seite konnte nicht gefunden werden.',
|
||||
},
|
||||
galleryCountdown: {
|
||||
expiresIn: 'Noch {days} Tage online',
|
||||
expiresToday: 'Letzter Tag!',
|
||||
expired: 'Galerie abgelaufen',
|
||||
description: 'Sichere jetzt deine Lieblingsfotos – die Galerie verschwindet bald.',
|
||||
expiredDescription: 'Nur die Veranstalter:innen können die Galerie jetzt noch verlängern.',
|
||||
ctaUpload: 'Letzte Fotos hochladen',
|
||||
},
|
||||
galleryPublic: {
|
||||
title: 'Galerie',
|
||||
loading: 'Galerie wird geladen ...',
|
||||
@@ -298,6 +306,63 @@ 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.',
|
||||
galleryWarningDay: 'Galerie läuft in {days} Tag ab. Teile deine Fotos rechtzeitig!',
|
||||
galleryWarningDays: 'Galerie läuft in {days} Tagen ab. Teile deine Fotos rechtzeitig!',
|
||||
status: {
|
||||
title: 'Dein Paketstatus',
|
||||
subtitle: 'Behalte deine Kontingente im Blick, bevor es eng wird.',
|
||||
badges: {
|
||||
ok: 'Bereit',
|
||||
warning: 'Hinweis',
|
||||
limit_reached: 'Limit erreicht',
|
||||
unlimited: 'Unbegrenzt',
|
||||
},
|
||||
cards: {
|
||||
photos: {
|
||||
title: 'Fotos',
|
||||
remaining: 'Nur noch {remaining} von {limit} Fotos möglich',
|
||||
unlimited: 'Unbegrenzte Foto-Uploads inklusive',
|
||||
},
|
||||
guests: {
|
||||
title: 'Gäste',
|
||||
remaining: '{remaining} Gäste frei (max. {limit})',
|
||||
unlimited: 'Beliebig viele Gäste erlaubt',
|
||||
},
|
||||
},
|
||||
},
|
||||
dialogs: {
|
||||
close: 'Verstanden',
|
||||
photoLimit: {
|
||||
title: 'Upload-Limit erreicht',
|
||||
description: 'Es wurden {used} von {limit} Fotos hochgeladen. Bitte kontaktiere das Team für ein Upgrade.',
|
||||
hint: 'Tipp: Größere Pakete schalten sofort wieder Uploads frei.',
|
||||
},
|
||||
deviceLimit: {
|
||||
title: 'Gerätelimit erreicht',
|
||||
description: 'Dieses Gerät hat sein Upload-Kontingent ausgeschöpft.',
|
||||
hint: 'Nutze ein anderes Gerät oder sprich die Veranstalter:innen an.',
|
||||
},
|
||||
packageMissing: {
|
||||
title: 'Event pausiert Uploads',
|
||||
description: 'Für dieses Event sind aktuell keine Uploads freigeschaltet.',
|
||||
hint: 'Bitte kontaktiere die Veranstalter:innen für weitere Informationen.',
|
||||
},
|
||||
galleryExpired: {
|
||||
title: 'Galerie abgelaufen',
|
||||
description: 'Die Galerie ist geschlossen – neue Uploads sind nicht mehr möglich.',
|
||||
hint: 'Nur die Veranstalter:innen können die Galerie verlängern.',
|
||||
},
|
||||
csrf: {
|
||||
title: 'Sitzung abgelaufen',
|
||||
description: 'Bitte lade die Seite neu und versuche den Upload anschließend erneut.',
|
||||
hint: 'Aktualisiere die Seite, um eine neue Sitzung zu starten.',
|
||||
},
|
||||
generic: {
|
||||
title: 'Upload fehlgeschlagen',
|
||||
description: 'Der Upload konnte nicht abgeschlossen werden. Bitte versuche es später erneut.',
|
||||
hint: 'Bleibt das Problem bestehen, kontaktiere die Veranstalter:innen.',
|
||||
},
|
||||
},
|
||||
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.',
|
||||
@@ -551,6 +616,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
title: 'Not found',
|
||||
description: 'We could not find the page you requested.',
|
||||
},
|
||||
galleryCountdown: {
|
||||
expiresIn: '{days} days remaining',
|
||||
expiresToday: 'Final day!',
|
||||
expired: 'Gallery expired',
|
||||
description: 'Save your favourite photos before the gallery goes offline.',
|
||||
expiredDescription: 'Only the organizers can extend the gallery now.',
|
||||
ctaUpload: 'Share last photos',
|
||||
},
|
||||
galleryPublic: {
|
||||
title: 'Gallery',
|
||||
loading: 'Loading gallery ...',
|
||||
@@ -661,6 +734,63 @@ 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.',
|
||||
galleryWarningDay: 'Gallery expires in {days} day. Upload your photos soon!',
|
||||
galleryWarningDays: 'Gallery expires in {days} days. Upload your photos soon!',
|
||||
status: {
|
||||
title: 'Your package status',
|
||||
subtitle: 'Keep an eye on your remaining allowances.',
|
||||
badges: {
|
||||
ok: 'Ready',
|
||||
warning: 'Heads-up',
|
||||
limit_reached: 'Limit reached',
|
||||
unlimited: 'Unlimited',
|
||||
},
|
||||
cards: {
|
||||
photos: {
|
||||
title: 'Photos',
|
||||
remaining: '{remaining} of {limit} photo slots left',
|
||||
unlimited: 'Unlimited photo uploads included',
|
||||
},
|
||||
guests: {
|
||||
title: 'Guests',
|
||||
remaining: '{remaining} guest slots free (max {limit})',
|
||||
unlimited: 'Unlimited guests allowed',
|
||||
},
|
||||
},
|
||||
},
|
||||
dialogs: {
|
||||
close: 'Got it',
|
||||
photoLimit: {
|
||||
title: 'Upload limit reached',
|
||||
description: '{used} of {limit} photos are already uploaded. Please reach out to upgrade your package.',
|
||||
hint: 'Tip: Upgrading the package re-enables uploads instantly.',
|
||||
},
|
||||
deviceLimit: {
|
||||
title: 'Device limit reached',
|
||||
description: 'This device has used all available upload slots.',
|
||||
hint: 'Try a different device or contact the organizers.',
|
||||
},
|
||||
packageMissing: {
|
||||
title: 'Uploads paused',
|
||||
description: 'This event is currently not accepting new uploads.',
|
||||
hint: 'Check in with the organizers for the latest status.',
|
||||
},
|
||||
galleryExpired: {
|
||||
title: 'Gallery closed',
|
||||
description: 'The gallery has expired and no new uploads can be added.',
|
||||
hint: 'Only the organizers can extend the gallery window.',
|
||||
},
|
||||
csrf: {
|
||||
title: 'Session expired',
|
||||
description: 'Refresh the page and try the upload again.',
|
||||
hint: 'Reload the page to start a new session.',
|
||||
},
|
||||
generic: {
|
||||
title: 'Upload failed',
|
||||
description: 'We could not complete the upload. Please try again later.',
|
||||
hint: 'If it keeps happening, reach out to the organizers.',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
photoLimit: 'Upload limit reached. Contact the organizers for an upgrade.',
|
||||
deviceLimit: 'This device reached its upload limit. Please contact the organizers.',
|
||||
|
||||
86
resources/js/guest/lib/__tests__/limitSummaries.test.ts
Normal file
86
resources/js/guest/lib/__tests__/limitSummaries.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { EventPackageLimits } from '../../services/eventApi';
|
||||
import { buildLimitSummaries } from '../limitSummaries';
|
||||
|
||||
const translations = new Map<string, string>([
|
||||
['upload.status.cards.photos.title', 'Fotos'],
|
||||
['upload.status.cards.photos.remaining', 'Noch {remaining} von {limit}'],
|
||||
['upload.status.cards.photos.unlimited', 'Unbegrenzte Uploads'],
|
||||
['upload.status.cards.guests.title', 'Gäste'],
|
||||
['upload.status.cards.guests.remaining', '{remaining} Gäste frei (max. {limit})'],
|
||||
['upload.status.cards.guests.unlimited', 'Unbegrenzte Gäste'],
|
||||
['upload.status.badges.ok', 'OK'],
|
||||
['upload.status.badges.warning', 'Warnung'],
|
||||
['upload.status.badges.limit_reached', 'Limit erreicht'],
|
||||
['upload.status.badges.unlimited', 'Unbegrenzt'],
|
||||
]);
|
||||
|
||||
const t = (key: string) => translations.get(key) ?? key;
|
||||
|
||||
describe('buildLimitSummaries', () => {
|
||||
it('builds photo summary with progress and warning tone', () => {
|
||||
const limits: EventPackageLimits = {
|
||||
photos: {
|
||||
limit: 100,
|
||||
used: 80,
|
||||
remaining: 20,
|
||||
percentage: 80,
|
||||
state: 'warning',
|
||||
threshold_reached: 80,
|
||||
next_threshold: 95,
|
||||
thresholds: [80, 95],
|
||||
},
|
||||
guests: null,
|
||||
gallery: null,
|
||||
can_upload_photos: true,
|
||||
can_add_guests: true,
|
||||
};
|
||||
|
||||
const cards = buildLimitSummaries(limits, t);
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
const card = cards[0];
|
||||
expect(card.id).toBe('photos');
|
||||
expect(card.tone).toBe('warning');
|
||||
expect(card.progress).toBe(80);
|
||||
expect(card.valueLabel).toBe('80 / 100');
|
||||
expect(card.description).toBe('Noch 20 von 100');
|
||||
expect(card.badgeLabel).toBe('Warnung');
|
||||
});
|
||||
|
||||
it('builds unlimited guest summary without progress', () => {
|
||||
const limits: EventPackageLimits = {
|
||||
photos: null,
|
||||
guests: {
|
||||
limit: null,
|
||||
used: 5,
|
||||
remaining: null,
|
||||
percentage: null,
|
||||
state: 'unlimited',
|
||||
threshold_reached: null,
|
||||
next_threshold: null,
|
||||
thresholds: [],
|
||||
},
|
||||
gallery: null,
|
||||
can_upload_photos: true,
|
||||
can_add_guests: true,
|
||||
};
|
||||
|
||||
const cards = buildLimitSummaries(limits, t);
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
const card = cards[0];
|
||||
expect(card.id).toBe('guests');
|
||||
expect(card.progress).toBeNull();
|
||||
expect(card.tone).toBe('neutral');
|
||||
expect(card.valueLabel).toBe('Unbegrenzt');
|
||||
expect(card.description).toBe('Unbegrenzte Gäste');
|
||||
expect(card.badgeLabel).toBe('Unbegrenzt');
|
||||
});
|
||||
|
||||
it('returns empty list when no limits are provided', () => {
|
||||
expect(buildLimitSummaries(null, t)).toEqual([]);
|
||||
expect(buildLimitSummaries(undefined, t)).toEqual([]);
|
||||
});
|
||||
});
|
||||
49
resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts
Normal file
49
resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveUploadErrorDialog } from '../uploadErrorDialog';
|
||||
|
||||
const translations = new Map<string, string>([
|
||||
['upload.dialogs.photoLimit.title', 'Upload-Limit erreicht'],
|
||||
['upload.dialogs.photoLimit.description', 'Es wurden {used} von {limit} Fotos hochgeladen. Es bleiben {remaining}.'],
|
||||
['upload.dialogs.photoLimit.hint', 'Wende dich an das Team.'],
|
||||
['upload.dialogs.deviceLimit.title', 'Dieses Gerät ist voll'],
|
||||
['upload.dialogs.deviceLimit.description', 'Du hast das Geräte-Limit erreicht.'],
|
||||
['upload.dialogs.deviceLimit.hint', 'Nutze ein anderes Gerät oder kontaktiere das Team.'],
|
||||
['upload.dialogs.packageMissing.title', 'Event nicht bereit'],
|
||||
['upload.dialogs.packageMissing.description', 'Das Event akzeptiert aktuell keine Uploads.'],
|
||||
['upload.dialogs.packageMissing.hint', 'Frag die Veranstalter:innen nach dem Status.'],
|
||||
['upload.dialogs.galleryExpired.title', 'Galerie abgelaufen'],
|
||||
['upload.dialogs.galleryExpired.description', 'Uploads sind nicht mehr möglich.'],
|
||||
['upload.dialogs.galleryExpired.hint', 'Bitte wende dich an die Veranstalter:innen.'],
|
||||
['upload.dialogs.csrf.title', 'Sicherheitsabgleich erforderlich'],
|
||||
['upload.dialogs.csrf.description', 'Bitte lade die Seite neu und versuche es erneut.'],
|
||||
['upload.dialogs.csrf.hint', 'Aktualisiere die Seite.'],
|
||||
['upload.dialogs.generic.title', 'Upload fehlgeschlagen'],
|
||||
['upload.dialogs.generic.description', 'Der Upload konnte nicht abgeschlossen werden.'],
|
||||
['upload.dialogs.generic.hint', 'Versuche es später erneut.'],
|
||||
]);
|
||||
|
||||
const t = (key: string) => translations.get(key) ?? key;
|
||||
|
||||
describe('resolveUploadErrorDialog', () => {
|
||||
it('renders photo limit dialog with placeholders', () => {
|
||||
const dialog = resolveUploadErrorDialog(
|
||||
'photo_limit_exceeded',
|
||||
{ used: 120, limit: 120, remaining: 0 },
|
||||
t
|
||||
);
|
||||
|
||||
expect(dialog.title).toBe('Upload-Limit erreicht');
|
||||
expect(dialog.description).toBe('Es wurden 120 von 120 Fotos hochgeladen. Es bleiben 0.');
|
||||
expect(dialog.hint).toBe('Wende dich an das Team.');
|
||||
expect(dialog.tone).toBe('danger');
|
||||
});
|
||||
|
||||
it('falls back to generic dialog when code is unknown', () => {
|
||||
const dialog = resolveUploadErrorDialog('something_else', undefined, t);
|
||||
|
||||
expect(dialog.tone).toBe('info');
|
||||
expect(dialog.title).toBe('Upload fehlgeschlagen');
|
||||
expect(dialog.description).toBe('Der Upload konnte nicht abgeschlossen werden.');
|
||||
});
|
||||
});
|
||||
107
resources/js/guest/lib/limitSummaries.ts
Normal file
107
resources/js/guest/lib/limitSummaries.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { EventPackageLimits, LimitUsageSummary } from '../services/eventApi';
|
||||
|
||||
export type LimitTone = 'neutral' | 'warning' | 'danger';
|
||||
|
||||
export type LimitSummaryCard = {
|
||||
id: 'photos' | 'guests';
|
||||
label: string;
|
||||
state: LimitUsageSummary['state'];
|
||||
tone: LimitTone;
|
||||
used: number;
|
||||
limit: number | null;
|
||||
remaining: number | null;
|
||||
progress: number | null;
|
||||
valueLabel: string;
|
||||
description: string;
|
||||
badgeLabel: string;
|
||||
};
|
||||
|
||||
type TranslateFn = (key: string, fallback?: string) => string;
|
||||
|
||||
function resolveTone(state: LimitUsageSummary['state']): LimitTone {
|
||||
if (state === 'limit_reached') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
if (state === 'warning') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function buildCard(
|
||||
id: 'photos' | 'guests',
|
||||
summary: LimitUsageSummary,
|
||||
t: TranslateFn
|
||||
): LimitSummaryCard {
|
||||
const labelKey = id === 'photos' ? 'upload.status.cards.photos.title' : 'upload.status.cards.guests.title';
|
||||
const remainingKey = id === 'photos'
|
||||
? 'upload.status.cards.photos.remaining'
|
||||
: 'upload.status.cards.guests.remaining';
|
||||
const unlimitedKey = id === 'photos'
|
||||
? 'upload.status.cards.photos.unlimited'
|
||||
: 'upload.status.cards.guests.unlimited';
|
||||
|
||||
const tone = resolveTone(summary.state);
|
||||
const progress = typeof summary.limit === 'number' && summary.limit > 0
|
||||
? Math.min(100, Math.round((summary.used / summary.limit) * 100))
|
||||
: null;
|
||||
|
||||
const valueLabel = typeof summary.limit === 'number' && summary.limit > 0
|
||||
? `${summary.used.toLocaleString()} / ${summary.limit.toLocaleString()}`
|
||||
: t('upload.status.badges.unlimited');
|
||||
|
||||
const description = summary.state === 'unlimited'
|
||||
? t(unlimitedKey)
|
||||
: summary.remaining !== null && summary.limit !== null
|
||||
? t(remainingKey)
|
||||
.replace('{remaining}', `${Math.max(0, summary.remaining)}`)
|
||||
.replace('{limit}', `${summary.limit}`)
|
||||
: valueLabel;
|
||||
|
||||
const badgeKey = (() => {
|
||||
switch (summary.state) {
|
||||
case 'limit_reached':
|
||||
return 'upload.status.badges.limit_reached';
|
||||
case 'warning':
|
||||
return 'upload.status.badges.warning';
|
||||
case 'unlimited':
|
||||
return 'upload.status.badges.unlimited';
|
||||
default:
|
||||
return 'upload.status.badges.ok';
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
id,
|
||||
label: t(labelKey),
|
||||
state: summary.state,
|
||||
tone,
|
||||
used: summary.used,
|
||||
limit: summary.limit,
|
||||
remaining: summary.remaining,
|
||||
progress,
|
||||
valueLabel,
|
||||
description,
|
||||
badgeLabel: t(badgeKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLimitSummaries(limits: EventPackageLimits | null | undefined, t: TranslateFn): LimitSummaryCard[] {
|
||||
if (!limits) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cards: LimitSummaryCard[] = [];
|
||||
|
||||
if (limits.photos) {
|
||||
cards.push(buildCard('photos', limits.photos, t));
|
||||
}
|
||||
|
||||
if (limits.guests) {
|
||||
cards.push(buildCard('guests', limits.guests, t));
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
94
resources/js/guest/lib/uploadErrorDialog.ts
Normal file
94
resources/js/guest/lib/uploadErrorDialog.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { TranslateFn } from '../i18n/useTranslation';
|
||||
|
||||
export type UploadErrorDialog = {
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
hint?: string;
|
||||
tone: 'danger' | 'warning' | 'info';
|
||||
};
|
||||
|
||||
function formatWithNumbers(template: string, values: Record<string, number | string | undefined>): string {
|
||||
return Object.entries(values).reduce((acc, [key, value]) => {
|
||||
if (value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return acc.replaceAll(`{${key}}`, String(value));
|
||||
}, template);
|
||||
}
|
||||
|
||||
export function resolveUploadErrorDialog(
|
||||
code: string | undefined,
|
||||
meta: Record<string, unknown> | undefined,
|
||||
t: TranslateFn
|
||||
): UploadErrorDialog {
|
||||
const normalized = (code ?? 'unknown').toLowerCase();
|
||||
const getNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);
|
||||
|
||||
switch (normalized) {
|
||||
case 'photo_limit_exceeded': {
|
||||
const used = getNumber(meta?.used);
|
||||
const limit = getNumber(meta?.limit);
|
||||
const remaining = getNumber(meta?.remaining);
|
||||
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'danger',
|
||||
title: t('upload.dialogs.photoLimit.title'),
|
||||
description: formatWithNumbers(t('upload.dialogs.photoLimit.description'), {
|
||||
used,
|
||||
limit,
|
||||
remaining,
|
||||
}),
|
||||
hint: t('upload.dialogs.photoLimit.hint'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'upload_device_limit':
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'warning',
|
||||
title: t('upload.dialogs.deviceLimit.title'),
|
||||
description: t('upload.dialogs.deviceLimit.description'),
|
||||
hint: t('upload.dialogs.deviceLimit.hint'),
|
||||
};
|
||||
|
||||
case 'event_package_missing':
|
||||
case 'event_not_found':
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'info',
|
||||
title: t('upload.dialogs.packageMissing.title'),
|
||||
description: t('upload.dialogs.packageMissing.description'),
|
||||
hint: t('upload.dialogs.packageMissing.hint'),
|
||||
};
|
||||
|
||||
case 'gallery_expired':
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'danger',
|
||||
title: t('upload.dialogs.galleryExpired.title'),
|
||||
description: t('upload.dialogs.galleryExpired.description'),
|
||||
hint: t('upload.dialogs.galleryExpired.hint'),
|
||||
};
|
||||
|
||||
case 'csrf_mismatch':
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'warning',
|
||||
title: t('upload.dialogs.csrf.title'),
|
||||
description: t('upload.dialogs.csrf.description'),
|
||||
hint: t('upload.dialogs.csrf.hint'),
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'info',
|
||||
title: t('upload.dialogs.generic.title'),
|
||||
description: t('upload.dialogs.generic.description'),
|
||||
hint: t('upload.dialogs.generic.hint'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
||||
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon } from 'lucide-react';
|
||||
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon, AlertTriangle } from 'lucide-react';
|
||||
import { likePhoto } from '../services/photosApi';
|
||||
import PhotoLightbox from './PhotoLightbox';
|
||||
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { token } = useParams<{ token?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
|
||||
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||
@@ -22,6 +25,8 @@ export default function GalleryPage() {
|
||||
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
||||
const [stats, setStats] = useState<EventStats | null>(null);
|
||||
const [eventLoading, setEventLoading] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const photoIdParam = searchParams.get('photoId');
|
||||
@@ -82,6 +87,109 @@ export default function GalleryPage() {
|
||||
const [liked, setLiked] = React.useState<Set<number>>(new Set());
|
||||
const [counts, setCounts] = React.useState<Record<number, number>>({});
|
||||
|
||||
const photoLimits = eventPackage?.limits?.photos ?? null;
|
||||
const guestLimits = eventPackage?.limits?.guests ?? null;
|
||||
const galleryLimits = eventPackage?.limits?.gallery ?? null;
|
||||
|
||||
const galleryCountdown = React.useMemo(() => {
|
||||
if (!galleryLimits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (galleryLimits.state === 'expired') {
|
||||
return {
|
||||
tone: 'danger' as const,
|
||||
label: t('galleryCountdown.expired'),
|
||||
description: t('galleryCountdown.expiredDescription'),
|
||||
cta: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (galleryLimits.state === 'warning') {
|
||||
const days = Math.max(0, galleryLimits.days_remaining ?? 0);
|
||||
const label = days <= 1
|
||||
? t('galleryCountdown.expiresToday')
|
||||
: t('galleryCountdown.expiresIn').replace('{days}', `${days}`);
|
||||
|
||||
return {
|
||||
tone: days <= 1 ? ('danger' as const) : ('warning' as const),
|
||||
label,
|
||||
description: t('galleryCountdown.description'),
|
||||
cta: {
|
||||
type: 'upload' as const,
|
||||
label: t('galleryCountdown.ctaUpload'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [galleryLimits, t]);
|
||||
|
||||
const handleCountdownCta = React.useCallback(() => {
|
||||
if (!galleryCountdown?.cta || !token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (galleryCountdown.cta.type === 'upload') {
|
||||
navigate(`/e/${encodeURIComponent(token)}/upload`);
|
||||
}
|
||||
}, [galleryCountdown?.cta, navigate, token]);
|
||||
|
||||
const packageWarnings = React.useMemo(() => {
|
||||
const warnings: { id: string; tone: 'warning' | 'danger'; message: string }[] = [];
|
||||
|
||||
if (photoLimits?.state === 'limit_reached' && typeof photoLimits.limit === 'number') {
|
||||
warnings.push({
|
||||
id: 'photos-blocked',
|
||||
tone: 'danger',
|
||||
message: t('upload.limitReached')
|
||||
.replace('{used}', `${photoLimits.used}`)
|
||||
.replace('{max}', `${photoLimits.limit}`),
|
||||
});
|
||||
} else if (
|
||||
photoLimits?.state === 'warning'
|
||||
&& typeof photoLimits.remaining === 'number'
|
||||
&& typeof photoLimits.limit === 'number'
|
||||
) {
|
||||
warnings.push({
|
||||
id: 'photos-warning',
|
||||
tone: 'warning',
|
||||
message: t('upload.limitWarning')
|
||||
.replace('{remaining}', `${photoLimits.remaining}`)
|
||||
.replace('{max}', `${photoLimits.limit}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (galleryLimits?.state === 'expired') {
|
||||
warnings.push({
|
||||
id: 'gallery-expired',
|
||||
tone: 'danger',
|
||||
message: t('upload.errors.galleryExpired'),
|
||||
});
|
||||
} else if (galleryLimits?.state === 'warning') {
|
||||
const days = Math.max(0, galleryLimits.days_remaining ?? 0);
|
||||
const key = days === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays';
|
||||
warnings.push({
|
||||
id: 'gallery-warning',
|
||||
tone: 'warning',
|
||||
message: t(key).replace('{days}', `${days}`),
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}, [photoLimits, galleryLimits, t]);
|
||||
|
||||
const formatDate = React.useCallback((value: string | null) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
async function onLike(id: number) {
|
||||
if (liked.has(id)) return;
|
||||
setLiked(new Set(liked).add(id));
|
||||
@@ -111,17 +219,62 @@ export default function GalleryPage() {
|
||||
<Page title="Galerie">
|
||||
<Card className="mx-4 mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
Galerie: {event?.name || 'Event'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
|
||||
<p className="font-semibold">Online Gäste</p>
|
||||
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex flex-wrap items-center gap-2">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
<span>Galerie: {event?.name || 'Event'}</span>
|
||||
{galleryCountdown && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={galleryCountdown.tone === 'danger'
|
||||
? 'border-rose-200 bg-rose-100 text-rose-700'
|
||||
: 'border-amber-200 bg-amber-100 text-amber-700'}
|
||||
>
|
||||
{galleryCountdown.label}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{galleryCountdown?.cta && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={galleryCountdown.tone === 'danger' ? 'destructive' : 'outline'}
|
||||
onClick={handleCountdownCta}
|
||||
disabled={!token}
|
||||
>
|
||||
{galleryCountdown.cta.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{galleryCountdown && (
|
||||
<CardDescription className={galleryCountdown.tone === 'danger' ? 'text-rose-600' : 'text-amber-600'}>
|
||||
{galleryCountdown.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{packageWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{packageWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="text-center">
|
||||
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
|
||||
<p className="font-semibold">Online Gäste</p>
|
||||
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
|
||||
<p className="font-semibold">Gesamt Likes</p>
|
||||
@@ -133,24 +286,38 @@ export default function GalleryPage() {
|
||||
<p className="text-2xl">{photos.length}</p>
|
||||
</div>
|
||||
{eventPackage && (
|
||||
<div className="text-center">
|
||||
<PackageIcon className="h-8 w-8 mx-auto mb-2 text-purple-500" />
|
||||
<div className="rounded-2xl border border-gray-200 bg-white/70 p-4 text-center">
|
||||
<PackageIcon className="mx-auto mb-2 h-8 w-8 text-purple-500" />
|
||||
<p className="font-semibold">Package</p>
|
||||
<p className="text-sm">{eventPackage.package.name}</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(eventPackage.used_photos / eventPackage.package.max_photos) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{eventPackage.used_photos} / {eventPackage.package.max_photos} Fotos
|
||||
</p>
|
||||
{new Date(eventPackage.expires_at) < new Date() && (
|
||||
<p className="text-red-600 text-xs mt-1">Abgelaufen: {new Date(eventPackage.expires_at).toLocaleDateString()}</p>
|
||||
<p className="text-sm text-gray-600">{eventPackage.package?.name ?? '—'}</p>
|
||||
{photoLimits?.limit ? (
|
||||
<>
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className={`h-2 rounded-full ${photoLimits.state === 'limit_reached' ? 'bg-red-500' : photoLimits.state === 'warning' ? 'bg-amber-500' : 'bg-blue-600'}`}
|
||||
style={{ width: `${Math.min(100, Math.max(6, Math.round((photoLimits.used / photoLimits.limit) * 100))) }%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600">
|
||||
{photoLimits.used} / {photoLimits.limit} Fotos
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-gray-600">{t('upload.limitUnlimited')}</p>
|
||||
)}
|
||||
{guestLimits?.limit ? (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Gäste: {guestLimits.used} / {guestLimits.limit}
|
||||
</p>
|
||||
) : null}
|
||||
{galleryLimits?.expires_at ? (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Galerie bis {formatDate(galleryLimits.expires_at)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -19,13 +19,48 @@ export interface PackageData {
|
||||
id: number;
|
||||
name: string;
|
||||
max_photos: number;
|
||||
max_guests?: number | null;
|
||||
gallery_days?: number | null;
|
||||
}
|
||||
|
||||
export interface LimitUsageSummary {
|
||||
limit: number | null;
|
||||
used: number;
|
||||
remaining: number | null;
|
||||
percentage: number | null;
|
||||
state: 'ok' | 'warning' | 'limit_reached' | 'unlimited';
|
||||
threshold_reached: number | null;
|
||||
next_threshold: number | null;
|
||||
thresholds: number[];
|
||||
}
|
||||
|
||||
export interface GallerySummary {
|
||||
state: 'ok' | 'warning' | 'expired' | 'unlimited';
|
||||
expires_at: string | null;
|
||||
days_remaining: number | null;
|
||||
warning_thresholds: number[];
|
||||
warning_triggered: number | null;
|
||||
warning_sent_at: string | null;
|
||||
expired_notified_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventPackageLimits {
|
||||
photos: LimitUsageSummary | null;
|
||||
guests: LimitUsageSummary | null;
|
||||
gallery: GallerySummary | null;
|
||||
can_upload_photos: boolean;
|
||||
can_add_guests: boolean;
|
||||
}
|
||||
|
||||
export interface EventPackage {
|
||||
id: number;
|
||||
event_id?: number;
|
||||
package_id?: number;
|
||||
used_photos: number;
|
||||
expires_at: string;
|
||||
package: PackageData;
|
||||
used_guests?: number;
|
||||
expires_at: string | null;
|
||||
package: PackageData | null;
|
||||
limits: EventPackageLimits | null;
|
||||
}
|
||||
|
||||
export interface EventStats {
|
||||
@@ -39,6 +74,8 @@ export type FetchEventErrorCode =
|
||||
| 'token_expired'
|
||||
| 'token_revoked'
|
||||
| 'token_rate_limited'
|
||||
| 'access_rate_limited'
|
||||
| 'gallery_expired'
|
||||
| 'event_not_public'
|
||||
| 'network_error'
|
||||
| 'server_error'
|
||||
@@ -195,5 +232,9 @@ export async function getEventPackage(eventToken: string): Promise<EventPackage
|
||||
if (res.status === 404) return null;
|
||||
throw new Error('Failed to load event package');
|
||||
}
|
||||
return await res.json();
|
||||
const payload = await res.json();
|
||||
return {
|
||||
...payload,
|
||||
limits: payload?.limits ?? null,
|
||||
} as EventPackage;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user