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