refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isUploadPath, shouldShowAnalyticsNudge } from '../analyticsConsent';
|
||||
|
||||
describe('isUploadPath', () => {
|
||||
it('detects upload routes', () => {
|
||||
expect(isUploadPath('/e/abc/upload')).toBe(true);
|
||||
expect(isUploadPath('/e/abc/upload/queue')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-upload routes', () => {
|
||||
expect(isUploadPath('/e/abc/gallery')).toBe(false);
|
||||
expect(isUploadPath('/settings')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowAnalyticsNudge', () => {
|
||||
const baseState = {
|
||||
decisionMade: false,
|
||||
analyticsConsent: false,
|
||||
snoozedUntil: null,
|
||||
now: 1000,
|
||||
activeSeconds: 60,
|
||||
routeCount: 2,
|
||||
thresholdSeconds: 60,
|
||||
thresholdRoutes: 2,
|
||||
isUpload: false,
|
||||
};
|
||||
|
||||
it('returns true when thresholds are met', () => {
|
||||
expect(shouldShowAnalyticsNudge(baseState)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when consent decision is made', () => {
|
||||
expect(shouldShowAnalyticsNudge({ ...baseState, decisionMade: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when snoozed', () => {
|
||||
expect(shouldShowAnalyticsNudge({ ...baseState, snoozedUntil: 2000 })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on upload routes', () => {
|
||||
expect(shouldShowAnalyticsNudge({ ...baseState, isUpload: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { supportsBadging, updateAppBadge } from '../badges';
|
||||
|
||||
const originalSet = (navigator as any).setAppBadge;
|
||||
const originalClear = (navigator as any).clearAppBadge;
|
||||
const hadSet = 'setAppBadge' in navigator;
|
||||
const hadClear = 'clearAppBadge' in navigator;
|
||||
|
||||
function restoreNavigator() {
|
||||
if (hadSet) {
|
||||
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: originalSet });
|
||||
} else {
|
||||
delete (navigator as any).setAppBadge;
|
||||
}
|
||||
if (hadClear) {
|
||||
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: originalClear });
|
||||
} else {
|
||||
delete (navigator as any).clearAppBadge;
|
||||
}
|
||||
}
|
||||
|
||||
describe('badges', () => {
|
||||
afterEach(() => {
|
||||
restoreNavigator();
|
||||
});
|
||||
|
||||
it('sets the badge count when supported', async () => {
|
||||
const setAppBadge = vi.fn();
|
||||
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: setAppBadge });
|
||||
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: vi.fn() });
|
||||
|
||||
expect(supportsBadging()).toBe(true);
|
||||
await updateAppBadge(4);
|
||||
expect(setAppBadge).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('clears the badge when count is zero', async () => {
|
||||
const clearAppBadge = vi.fn();
|
||||
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: vi.fn() });
|
||||
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: clearAppBadge });
|
||||
|
||||
await updateAppBadge(0);
|
||||
expect(clearAppBadge).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no-ops when unsupported', async () => {
|
||||
delete (navigator as any).setAppBadge;
|
||||
delete (navigator as any).clearAppBadge;
|
||||
expect(supportsBadging()).toBe(false);
|
||||
await updateAppBadge(3);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldCacheResponse } from '../cachePolicy';
|
||||
|
||||
describe('shouldCacheResponse', () => {
|
||||
it('returns false when Cache-Control is no-store', () => {
|
||||
const response = new Response('ok', { headers: { 'Cache-Control': 'no-store' } });
|
||||
expect(shouldCacheResponse(response)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when Cache-Control is private', () => {
|
||||
const response = new Response('ok', { headers: { 'Cache-Control': 'private, max-age=0' } });
|
||||
expect(shouldCacheResponse(response)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when Pragma is no-cache', () => {
|
||||
const response = new Response('ok', { headers: { Pragma: 'no-cache' } });
|
||||
expect(shouldCacheResponse(response)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for cacheable responses', () => {
|
||||
const response = new Response('ok', { headers: { 'Cache-Control': 'public, max-age=60' } });
|
||||
expect(shouldCacheResponse(response)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { buildCsrfHeaders } from '../csrf';
|
||||
|
||||
describe('buildCsrfHeaders', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('device-id', 'device-123');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
document.head.querySelectorAll('meta[name="csrf-token"]').forEach((node) => node.remove());
|
||||
document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
});
|
||||
|
||||
it('reads token from meta tag', () => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'csrf-token');
|
||||
meta.setAttribute('content', 'meta-token');
|
||||
document.head.appendChild(meta);
|
||||
|
||||
const headers = buildCsrfHeaders('device-xyz');
|
||||
expect(headers['X-CSRF-TOKEN']).toBe('meta-token');
|
||||
expect(headers['X-XSRF-TOKEN']).toBe('meta-token');
|
||||
expect(headers['X-Device-Id']).toBe('device-xyz');
|
||||
});
|
||||
|
||||
it('falls back to cookie token', () => {
|
||||
const raw = btoa('cookie-token');
|
||||
document.cookie = `XSRF-TOKEN=${raw}; path=/`;
|
||||
|
||||
const headers = buildCsrfHeaders();
|
||||
expect(headers['X-CSRF-TOKEN']).toBe('cookie-token');
|
||||
expect(headers['X-XSRF-TOKEN']).toBe('cookie-token');
|
||||
expect(headers['X-Device-Id']).toBe('device-123');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { shouldShowPhotoboothFilter } from '../galleryFilters';
|
||||
|
||||
describe('shouldShowPhotoboothFilter', () => {
|
||||
it('returns true when photobooth is enabled', () => {
|
||||
expect(shouldShowPhotoboothFilter({ photobooth_enabled: true } as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when photobooth is disabled or missing', () => {
|
||||
expect(shouldShowPhotoboothFilter({ photobooth_enabled: false } as any)).toBe(false);
|
||||
expect(shouldShowPhotoboothFilter(null)).toBe(false);
|
||||
expect(shouldShowPhotoboothFilter(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { applyGuestTheme } from '../guestTheme';
|
||||
|
||||
const baseTheme = {
|
||||
primary: '#ff3366',
|
||||
secondary: '#ff99aa',
|
||||
background: '#111111',
|
||||
surface: '#222222',
|
||||
mode: 'dark' as const,
|
||||
};
|
||||
|
||||
describe('applyGuestTheme', () => {
|
||||
afterEach(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('guest-theme', 'dark');
|
||||
root.style.removeProperty('color-scheme');
|
||||
root.style.removeProperty('--guest-primary');
|
||||
root.style.removeProperty('--guest-secondary');
|
||||
root.style.removeProperty('--guest-background');
|
||||
root.style.removeProperty('--guest-surface');
|
||||
});
|
||||
|
||||
it('applies and restores guest theme settings', () => {
|
||||
const cleanup = applyGuestTheme(baseTheme);
|
||||
|
||||
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#111111');
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HAPTICS_STORAGE_KEY, getHapticsPreference, isHapticsEnabled, setHapticsPreference, supportsHaptics, triggerHaptic } from '../haptics';
|
||||
|
||||
describe('haptics', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it('returns false when vibrate is unavailable', () => {
|
||||
const original = navigator.vibrate;
|
||||
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: undefined });
|
||||
expect(supportsHaptics()).toBe(false);
|
||||
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: original });
|
||||
});
|
||||
|
||||
it('returns stored preference when set', () => {
|
||||
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
|
||||
expect(getHapticsPreference()).toBe(true);
|
||||
setHapticsPreference(false);
|
||||
expect(getHapticsPreference()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports disabled when reduced motion is enabled', () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const vibrate = vi.fn();
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: true }),
|
||||
});
|
||||
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate });
|
||||
setHapticsPreference(true);
|
||||
|
||||
expect(isHapticsEnabled()).toBe(false);
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers vibration only when enabled', () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const vibrate = vi.fn();
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: false }),
|
||||
});
|
||||
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate });
|
||||
|
||||
triggerHaptic('selection');
|
||||
expect(vibrate).toHaveBeenCalled();
|
||||
setHapticsPreference(false);
|
||||
triggerHaptic('selection');
|
||||
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getHelpSlugForPathname } from '../helpRouting';
|
||||
|
||||
describe('getHelpSlugForPathname', () => {
|
||||
it('returns a getting-started slug for home paths', () => {
|
||||
expect(getHelpSlugForPathname('/')).toBe('getting-started');
|
||||
expect(getHelpSlugForPathname('/e/demo')).toBe('getting-started');
|
||||
});
|
||||
|
||||
it('returns null for help pages', () => {
|
||||
expect(getHelpSlugForPathname('/help')).toBeNull();
|
||||
expect(getHelpSlugForPathname('/help/gallery-and-sharing')).toBeNull();
|
||||
expect(getHelpSlugForPathname('/e/demo/help/gallery-and-sharing')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps gallery related pages', () => {
|
||||
expect(getHelpSlugForPathname('/e/demo/gallery')).toBe('gallery-and-sharing');
|
||||
expect(getHelpSlugForPathname('/e/demo/photo/123')).toBe('gallery-and-sharing');
|
||||
expect(getHelpSlugForPathname('/e/demo/slideshow')).toBe('gallery-and-sharing');
|
||||
});
|
||||
|
||||
it('maps upload related pages', () => {
|
||||
expect(getHelpSlugForPathname('/e/demo/upload')).toBe('uploading-photos');
|
||||
expect(getHelpSlugForPathname('/e/demo/queue')).toBe('upload-troubleshooting');
|
||||
});
|
||||
|
||||
it('maps tasks and achievements', () => {
|
||||
expect(getHelpSlugForPathname('/e/demo/tasks')).toBe('tasks-and-missions');
|
||||
expect(getHelpSlugForPathname('/e/demo/tasks/12')).toBe('tasks-and-missions');
|
||||
expect(getHelpSlugForPathname('/e/demo/achievements')).toBe('achievements-and-badges');
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { EventPackageLimits } from '../../services/eventApi';
|
||||
import { buildLimitSummaries } from '../limitSummaries';
|
||||
|
||||
const translations = new Map<string, string>([
|
||||
['upload.limitSummary.cards.photos.title', 'Fotos'],
|
||||
['upload.limitSummary.cards.photos.remaining', 'Noch {remaining} von {limit}'],
|
||||
['upload.limitSummary.cards.photos.unlimited', 'Unbegrenzte Uploads'],
|
||||
['upload.limitSummary.cards.guests.title', 'Gäste'],
|
||||
['upload.limitSummary.cards.guests.remaining', '{remaining} Gäste frei (max. {limit})'],
|
||||
['upload.limitSummary.cards.guests.unlimited', 'Unbegrenzte Gäste'],
|
||||
['upload.limitSummary.badges.ok', 'OK'],
|
||||
['upload.limitSummary.badges.warning', 'Warnung'],
|
||||
['upload.limitSummary.badges.limit_reached', 'Limit erreicht'],
|
||||
['upload.limitSummary.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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveLiveShowEffect } from '../liveShowEffects';
|
||||
|
||||
describe('resolveLiveShowEffect', () => {
|
||||
it('adds flash overlay for shutter flash preset', () => {
|
||||
const effect = resolveLiveShowEffect('shutter_flash', 80, false);
|
||||
expect(effect.flash).toBeDefined();
|
||||
expect(effect.frame.initial).toBeDefined();
|
||||
expect(effect.frame.animate).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps light effects simple without flash', () => {
|
||||
const effect = resolveLiveShowEffect('light_effects', 80, false);
|
||||
expect(effect.flash).toBeUndefined();
|
||||
});
|
||||
|
||||
it('honors reduced motion with basic fade', () => {
|
||||
const effect = resolveLiveShowEffect('film_cut', 80, true);
|
||||
expect(effect.flash).toBeUndefined();
|
||||
expect(effect.frame.initial).toEqual({ opacity: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion';
|
||||
|
||||
describe('getMotionContainerPropsForNavigation', () => {
|
||||
it('returns initial hidden for POP navigation', () => {
|
||||
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({
|
||||
variants: STAGGER_FAST,
|
||||
initial: 'hidden',
|
||||
animate: 'show',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips initial animation for PUSH navigation', () => {
|
||||
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'PUSH')).toEqual({
|
||||
variants: STAGGER_FAST,
|
||||
initial: false,
|
||||
animate: 'show',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables motion when not enabled', () => {
|
||||
expect(getMotionContainerPropsForNavigation(false, STAGGER_FAST, 'POP')).toEqual({
|
||||
initial: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMotionItemPropsForNavigation', () => {
|
||||
it('returns animate props for POP navigation', () => {
|
||||
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'POP')).toEqual({
|
||||
variants: FADE_UP,
|
||||
initial: 'hidden',
|
||||
animate: 'show',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips initial animation for PUSH navigation', () => {
|
||||
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'PUSH')).toEqual({
|
||||
variants: FADE_UP,
|
||||
initial: false,
|
||||
animate: 'show',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty props when motion disabled', () => {
|
||||
expect(getMotionItemPropsForNavigation(false, FADE_UP, 'POP')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { dedupeTasksById } from '../taskUtils';
|
||||
|
||||
describe('dedupeTasksById', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(dedupeTasksById([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the first occurrence and preserves order', () => {
|
||||
const tasks = [
|
||||
{ id: 1, title: 'A' },
|
||||
{ id: 2, title: 'B' },
|
||||
{ id: 1, title: 'A-dup' },
|
||||
{ id: 3, title: 'C' },
|
||||
];
|
||||
|
||||
expect(dedupeTasksById(tasks)).toEqual([
|
||||
{ id: 1, title: 'A' },
|
||||
{ id: 2, title: 'B' },
|
||||
{ id: 3, title: 'C' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
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.');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
export type AnalyticsNudgeState = {
|
||||
decisionMade: boolean;
|
||||
analyticsConsent: boolean;
|
||||
snoozedUntil: number | null;
|
||||
now: number;
|
||||
activeSeconds: number;
|
||||
routeCount: number;
|
||||
thresholdSeconds: number;
|
||||
thresholdRoutes: number;
|
||||
isUpload: boolean;
|
||||
};
|
||||
|
||||
export function isUploadPath(pathname: string): boolean {
|
||||
return /\/upload(?:\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
export function shouldShowAnalyticsNudge(state: AnalyticsNudgeState): boolean {
|
||||
if (state.decisionMade || state.analyticsConsent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.isUpload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.snoozedUntil && state.snoozedUntil > state.now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
state.activeSeconds >= state.thresholdSeconds &&
|
||||
state.routeCount >= state.thresholdRoutes
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
type BadgingNavigator = Navigator & {
|
||||
setAppBadge?: (contents?: number) => Promise<void> | void;
|
||||
clearAppBadge?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
function getNavigator(): BadgingNavigator | null {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return navigator as BadgingNavigator;
|
||||
}
|
||||
|
||||
export function supportsBadging(): boolean {
|
||||
const nav = getNavigator();
|
||||
return Boolean(nav && (typeof nav.setAppBadge === 'function' || typeof nav.clearAppBadge === 'function'));
|
||||
}
|
||||
|
||||
export async function updateAppBadge(count: number): Promise<void> {
|
||||
const nav = getNavigator();
|
||||
if (!nav) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
|
||||
if (!supportsBadging()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (safeCount > 0 && nav.setAppBadge) {
|
||||
await nav.setAppBadge(safeCount);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nav.clearAppBadge) {
|
||||
await nav.clearAppBadge();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nav.setAppBadge) {
|
||||
await nav.setAppBadge(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Updating app badge failed', error);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export function shouldCacheResponse(response: Response | null): boolean {
|
||||
if (!response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cacheControl = response.headers.get('Cache-Control') ?? '';
|
||||
const pragma = response.headers.get('Pragma') ?? '';
|
||||
const normalizedCacheControl = cacheControl.toLowerCase();
|
||||
const normalizedPragma = pragma.toLowerCase();
|
||||
|
||||
if (normalizedCacheControl.includes('no-store') || normalizedCacheControl.includes('private')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedPragma.includes('no-cache')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const normalized = hex.trim();
|
||||
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
|
||||
if (normalized.length === 4) {
|
||||
r = parseInt(normalized[1] + normalized[1], 16);
|
||||
g = parseInt(normalized[2] + normalized[2], 16);
|
||||
b = parseInt(normalized[3] + normalized[3], 16);
|
||||
} else {
|
||||
r = parseInt(normalized.slice(1, 3), 16);
|
||||
g = parseInt(normalized.slice(3, 5), 16);
|
||||
b = parseInt(normalized.slice(5, 7), 16);
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
export function relativeLuminance(hex: string): number {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalize = (channel: number) => {
|
||||
const c = channel / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
const r = normalize(rgb.r);
|
||||
const g = normalize(rgb.g);
|
||||
const b = normalize(rgb.b);
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
export function getContrastingTextColor(
|
||||
backgroundHex: string,
|
||||
lightColor = '#ffffff',
|
||||
darkColor = '#0f172a',
|
||||
): string {
|
||||
const luminance = relativeLuminance(backgroundHex);
|
||||
|
||||
return luminance > 0.5 ? darkColor : lightColor;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { getDeviceId } from './device';
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metaToken = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaToken instanceof HTMLMetaElement) {
|
||||
return metaToken.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
const name = 'XSRF-TOKEN=';
|
||||
const decodedCookie = decodeURIComponent(document.cookie ?? '');
|
||||
const parts = decodedCookie.split(';');
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trimStart();
|
||||
if (!trimmed.startsWith(name)) {
|
||||
continue;
|
||||
}
|
||||
const token = trimmed.substring(name.length);
|
||||
try {
|
||||
return decodeURIComponent(atob(token));
|
||||
} catch {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCsrfHeaders(deviceId?: string): Record<string, string> {
|
||||
const token = getCsrfToken();
|
||||
const resolvedDeviceId = deviceId ?? (typeof window !== 'undefined' ? getDeviceId() : undefined);
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
if (resolvedDeviceId) {
|
||||
headers['X-Device-Id'] = resolvedDeviceId;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['X-CSRF-TOKEN'] = token;
|
||||
headers['X-XSRF-TOKEN'] = token;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export function getDeviceId(): string {
|
||||
const KEY = 'device-id';
|
||||
let id = localStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = genId();
|
||||
localStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function genId() {
|
||||
// Simple UUID v4-ish generator
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
export type EmotionTheme = {
|
||||
gradientClass: string;
|
||||
gradientBackground: string;
|
||||
suggestionGradient: string;
|
||||
suggestionBorder: string;
|
||||
};
|
||||
|
||||
export type EmotionIdentity = {
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const themeFreude: EmotionTheme = {
|
||||
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
|
||||
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
|
||||
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
|
||||
};
|
||||
|
||||
const themeLiebe: EmotionTheme = {
|
||||
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
|
||||
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
|
||||
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
|
||||
};
|
||||
|
||||
const themeEkstase: EmotionTheme = {
|
||||
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
|
||||
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
|
||||
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
|
||||
};
|
||||
|
||||
const themeEntspannt: EmotionTheme = {
|
||||
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
|
||||
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
|
||||
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
|
||||
};
|
||||
|
||||
const themeBesinnlich: EmotionTheme = {
|
||||
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
|
||||
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
|
||||
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
|
||||
};
|
||||
|
||||
const themeUeberraschung: EmotionTheme = {
|
||||
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
|
||||
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
|
||||
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
|
||||
};
|
||||
|
||||
const themeDefault: EmotionTheme = {
|
||||
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
|
||||
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
|
||||
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
|
||||
};
|
||||
|
||||
const EMOTION_THEMES: Record<string, EmotionTheme> = {
|
||||
freude: themeFreude,
|
||||
happy: themeFreude,
|
||||
liebe: themeLiebe,
|
||||
romance: themeLiebe,
|
||||
romantik: themeLiebe,
|
||||
nostalgie: themeEntspannt,
|
||||
relaxed: themeEntspannt,
|
||||
ruehrung: themeBesinnlich,
|
||||
traurigkeit: themeBesinnlich,
|
||||
teamgeist: themeFreude,
|
||||
gemeinschaft: themeFreude,
|
||||
ueberraschung: themeUeberraschung,
|
||||
surprise: themeUeberraschung,
|
||||
ekstase: themeEkstase,
|
||||
excited: themeEkstase,
|
||||
besinnlichkeit: themeBesinnlich,
|
||||
sad: themeBesinnlich,
|
||||
default: themeDefault,
|
||||
};
|
||||
|
||||
const EMOTION_ICONS: Record<string, string> = {
|
||||
freude: '😊',
|
||||
happy: '😊',
|
||||
liebe: '❤️',
|
||||
romantik: '💞',
|
||||
nostalgie: '📼',
|
||||
ruehrung: '🥲',
|
||||
teamgeist: '🤝',
|
||||
ueberraschung: '😲',
|
||||
surprise: '😲',
|
||||
ekstase: '🤩',
|
||||
besinnlichkeit: '🕯️',
|
||||
};
|
||||
|
||||
function sluggify(value?: string | null): string {
|
||||
return (value ?? '')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
|
||||
if (!identity) return 'default';
|
||||
const nameKey = sluggify(identity.name);
|
||||
if (nameKey && EMOTION_THEMES[nameKey]) {
|
||||
return nameKey;
|
||||
}
|
||||
const slugKey = sluggify(identity.slug);
|
||||
if (slugKey && EMOTION_THEMES[slugKey]) {
|
||||
return slugKey;
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
export function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
|
||||
const key = resolveEmotionKey(identity);
|
||||
return EMOTION_THEMES[key] ?? themeDefault;
|
||||
}
|
||||
|
||||
export function getEmotionIcon(identity?: EmotionIdentity | null): string {
|
||||
const key = resolveEmotionKey(identity);
|
||||
return EMOTION_ICONS[key] ?? '✨';
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { EventData } from '../services/eventApi';
|
||||
|
||||
export function isTaskModeEnabled(event?: EventData | null): boolean {
|
||||
if (!event) return true;
|
||||
const mode = event.engagement_mode;
|
||||
if (!mode) return true;
|
||||
return mode === 'tasks';
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { EventData } from '../services/eventApi';
|
||||
|
||||
export function shouldShowPhotoboothFilter(event?: EventData | null): boolean {
|
||||
return Boolean(event?.photobooth_enabled);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
export type GuestThemePayload = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
mode?: 'light' | 'dark' | 'auto';
|
||||
};
|
||||
|
||||
type GuestThemeCleanup = () => void;
|
||||
|
||||
const prefersDarkScheme = (): boolean => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const applyColorScheme = (root: HTMLElement, theme: 'light' | 'dark') => {
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
root.style.colorScheme = 'dark';
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
};
|
||||
|
||||
export function applyGuestTheme(payload: GuestThemePayload): GuestThemeCleanup {
|
||||
if (typeof document === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const hadGuestTheme = root.classList.contains('guest-theme');
|
||||
const wasDark = root.classList.contains('dark');
|
||||
const previousColorScheme = root.style.colorScheme;
|
||||
const previousVars = {
|
||||
primary: root.style.getPropertyValue('--guest-primary'),
|
||||
secondary: root.style.getPropertyValue('--guest-secondary'),
|
||||
background: root.style.getPropertyValue('--guest-background'),
|
||||
surface: root.style.getPropertyValue('--guest-surface'),
|
||||
};
|
||||
|
||||
root.classList.add('guest-theme');
|
||||
root.style.setProperty('--guest-primary', payload.primary);
|
||||
root.style.setProperty('--guest-secondary', payload.secondary);
|
||||
root.style.setProperty('--guest-background', payload.background);
|
||||
root.style.setProperty('--guest-surface', payload.surface);
|
||||
|
||||
const mode = payload.mode ?? 'auto';
|
||||
if (mode === 'dark') {
|
||||
applyColorScheme(root, 'dark');
|
||||
} else if (mode === 'light') {
|
||||
applyColorScheme(root, 'light');
|
||||
} else {
|
||||
applyColorScheme(root, prefersDarkScheme() ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hadGuestTheme) {
|
||||
root.classList.add('guest-theme');
|
||||
} else {
|
||||
root.classList.remove('guest-theme');
|
||||
}
|
||||
|
||||
if (wasDark) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
|
||||
if (previousColorScheme) {
|
||||
root.style.colorScheme = previousColorScheme;
|
||||
} else {
|
||||
root.style.removeProperty('color-scheme');
|
||||
}
|
||||
|
||||
if (previousVars.primary) {
|
||||
root.style.setProperty('--guest-primary', previousVars.primary);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-primary');
|
||||
}
|
||||
|
||||
if (previousVars.secondary) {
|
||||
root.style.setProperty('--guest-secondary', previousVars.secondary);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-secondary');
|
||||
}
|
||||
|
||||
if (previousVars.background) {
|
||||
root.style.setProperty('--guest-background', previousVars.background);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-background');
|
||||
}
|
||||
|
||||
if (previousVars.surface) {
|
||||
root.style.setProperty('--guest-surface', previousVars.surface);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-surface');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { prefersReducedMotion } from './motion';
|
||||
|
||||
export type HapticPattern = 'selection' | 'light' | 'medium' | 'success' | 'error';
|
||||
|
||||
const PATTERNS: Record<HapticPattern, number | number[]> = {
|
||||
selection: 10,
|
||||
light: 15,
|
||||
medium: 30,
|
||||
success: [10, 30, 10],
|
||||
error: [20, 30, 20],
|
||||
};
|
||||
|
||||
export const HAPTICS_STORAGE_KEY = 'guestHapticsEnabled';
|
||||
|
||||
export function supportsHaptics(): boolean {
|
||||
return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
|
||||
}
|
||||
|
||||
export function getHapticsPreference(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(HAPTICS_STORAGE_KEY);
|
||||
if (raw === null) {
|
||||
return true;
|
||||
}
|
||||
return raw !== '0';
|
||||
} catch (error) {
|
||||
console.warn('Failed to read haptics preference', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function setHapticsPreference(enabled: boolean): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(HAPTICS_STORAGE_KEY, enabled ? '1' : '0');
|
||||
} catch (error) {
|
||||
console.warn('Failed to store haptics preference', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function isHapticsEnabled(): boolean {
|
||||
return getHapticsPreference() && supportsHaptics() && !prefersReducedMotion();
|
||||
}
|
||||
|
||||
export function triggerHaptic(pattern: HapticPattern): void {
|
||||
if (!isHapticsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.vibrate(PATTERNS[pattern]);
|
||||
} catch (error) {
|
||||
console.warn('Haptic feedback failed', error);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
export function getHelpSlugForPathname(pathname: string): string | null {
|
||||
if (!pathname) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = pathname
|
||||
.replace(/^\/e\/[^/]+/, '')
|
||||
.replace(/\/+$/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
if (!normalized || normalized === '/') {
|
||||
return 'getting-started';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/help')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/gallery') || normalized.startsWith('/photo') || normalized.startsWith('/slideshow')) {
|
||||
return 'gallery-and-sharing';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/upload')) {
|
||||
return 'uploading-photos';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/queue')) {
|
||||
return 'upload-troubleshooting';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/tasks')) {
|
||||
return 'tasks-and-missions';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/achievements')) {
|
||||
return 'achievements-and-badges';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/settings')) {
|
||||
return 'settings-and-cache';
|
||||
}
|
||||
|
||||
return 'how-fotospiel-works';
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// @ts-nocheck
|
||||
export async function compressPhoto(
|
||||
file: File,
|
||||
opts: { targetBytes?: number; maxEdge?: number; qualityStart?: number } = {}
|
||||
): Promise<File> {
|
||||
const targetBytes = opts.targetBytes ?? 1_500_000; // 1.5 MB
|
||||
const maxEdge = opts.maxEdge ?? 2560;
|
||||
const qualityStart = opts.qualityStart ?? 0.85;
|
||||
|
||||
// If already small and jpeg, return as-is
|
||||
if (file.size <= targetBytes && file.type === 'image/jpeg') return file;
|
||||
|
||||
const img = await loadImageBitmap(file);
|
||||
const { width, height } = fitWithin(img.width, img.height, maxEdge);
|
||||
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas unsupported');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Iteratively lower quality to fit target size
|
||||
let quality = qualityStart;
|
||||
let blob: Blob | null = await toBlob(canvas, 'image/jpeg', quality);
|
||||
if (!blob) throw new Error('Failed to encode image');
|
||||
|
||||
while (blob.size > targetBytes && quality > 0.5) {
|
||||
quality -= 0.05;
|
||||
const attempt = await toBlob(canvas, 'image/jpeg', quality);
|
||||
if (attempt) blob = attempt;
|
||||
else break;
|
||||
}
|
||||
|
||||
// If still too large, downscale further by 0.9 until it fits or edge < 800
|
||||
let currentWidth = width;
|
||||
let currentHeight = height;
|
||||
while (blob.size > targetBytes && Math.max(currentWidth, currentHeight) > 800) {
|
||||
currentWidth = Math.round(currentWidth * 0.9);
|
||||
currentHeight = Math.round(currentHeight * 0.9);
|
||||
const c2 = createCanvas(currentWidth, currentHeight);
|
||||
const c2ctx = c2.getContext('2d');
|
||||
if (!c2ctx) break;
|
||||
c2ctx.drawImage(canvas, 0, 0, currentWidth, currentHeight);
|
||||
const attempt = await toBlob(c2, 'image/jpeg', quality);
|
||||
if (attempt) blob = attempt;
|
||||
}
|
||||
|
||||
const outName = ensureJpegExtension(file.name);
|
||||
return new File([blob], outName, { type: 'image/jpeg', lastModified: Date.now() });
|
||||
}
|
||||
|
||||
function fitWithin(w: number, h: number, maxEdge: number) {
|
||||
const scale = Math.min(1, maxEdge / Math.max(w, h));
|
||||
return { width: Math.round(w * scale), height: Math.round(h * scale) };
|
||||
}
|
||||
|
||||
function createCanvas(w: number, h: number): HTMLCanvasElement {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h; return c;
|
||||
}
|
||||
|
||||
function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
|
||||
return new Promise(resolve => canvas.toBlob(resolve, type, quality));
|
||||
}
|
||||
|
||||
async function loadImageBitmap(file: File): Promise<CanvasImageSource> {
|
||||
const canBitmap = 'createImageBitmap' in window;
|
||||
|
||||
if (canBitmap) {
|
||||
try {
|
||||
return await createImageBitmap(file);
|
||||
} catch (error) {
|
||||
console.warn('Falling back to HTML image decode', error);
|
||||
}
|
||||
}
|
||||
|
||||
return await loadHtmlImage(file);
|
||||
}
|
||||
|
||||
function loadHtmlImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
||||
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureJpegExtension(name: string) {
|
||||
return name.replace(/\.(heic|heif|png|webp|jpg|jpeg)$/i, '') + '.jpg';
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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.limitSummary.cards.photos.title' : 'upload.limitSummary.cards.guests.title';
|
||||
const remainingKey = id === 'photos'
|
||||
? 'upload.limitSummary.cards.photos.remaining'
|
||||
: 'upload.limitSummary.cards.guests.remaining';
|
||||
const unlimitedKey = id === 'photos'
|
||||
? 'upload.limitSummary.cards.photos.unlimited'
|
||||
: 'upload.limitSummary.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.limitSummary.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.limitSummary.badges.limit_reached';
|
||||
case 'warning':
|
||||
return 'upload.limitSummary.badges.warning';
|
||||
case 'unlimited':
|
||||
return 'upload.limitSummary.badges.unlimited';
|
||||
default:
|
||||
return 'upload.limitSummary.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;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { MotionProps, Transition } from 'framer-motion';
|
||||
import { IOS_EASE, IOS_EASE_SOFT } from './motion';
|
||||
import type { LiveShowEffectPreset } from '../services/liveShowApi';
|
||||
|
||||
export type LiveShowEffectSpec = {
|
||||
frame: MotionProps;
|
||||
flash?: MotionProps;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function resolveIntensity(intensity: number): number {
|
||||
const safe = Number.isFinite(intensity) ? intensity : 70;
|
||||
return clamp(safe / 100, 0, 1);
|
||||
}
|
||||
|
||||
function buildTransition(duration: number, ease: Transition['ease']): Transition {
|
||||
return {
|
||||
duration,
|
||||
ease,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLiveShowEffect(
|
||||
preset: LiveShowEffectPreset,
|
||||
intensity: number,
|
||||
reducedMotion: boolean
|
||||
): LiveShowEffectSpec {
|
||||
const strength = reducedMotion ? 0 : resolveIntensity(intensity);
|
||||
const baseDuration = reducedMotion ? 0.2 : clamp(0.9 - strength * 0.35, 0.45, 1);
|
||||
const exitDuration = reducedMotion ? 0.15 : clamp(baseDuration * 0.6, 0.25, 0.6);
|
||||
|
||||
if (reducedMotion) {
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||
transition: buildTransition(baseDuration, IOS_EASE_SOFT),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
switch (preset) {
|
||||
case 'shutter_flash': {
|
||||
const scale = 1 + strength * 0.05;
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0, scale, y: 12 * strength },
|
||||
animate: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||
transition: buildTransition(baseDuration, IOS_EASE),
|
||||
},
|
||||
flash: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: [0, 0.85, 0], transition: { duration: 0.5, times: [0, 0.2, 1] } },
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'polaroid_toss': {
|
||||
const rotation = 3 + strength * 5;
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0, rotate: -rotation, scale: 0.9 },
|
||||
animate: { opacity: 1, rotate: 0, scale: 1 },
|
||||
exit: { opacity: 0, rotate: rotation * 0.5, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||
transition: buildTransition(baseDuration, IOS_EASE),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'parallax_glide': {
|
||||
const scale = 1 + strength * 0.06;
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0, scale, y: 24 * strength },
|
||||
animate: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 1.02, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||
transition: buildTransition(baseDuration + 0.2, IOS_EASE_SOFT),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'light_effects': {
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||
transition: buildTransition(baseDuration * 0.8, IOS_EASE_SOFT),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'film_cut':
|
||||
default: {
|
||||
const scale = 1 + strength * 0.03;
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0, scale },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.99, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||
transition: buildTransition(baseDuration, IOS_EASE),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
|
||||
type LocalizedRecord = Record<string, string | null | undefined>;
|
||||
|
||||
function pickLocalizedValue(record: LocalizedRecord, locale: LocaleCode): string | null {
|
||||
if (typeof record[locale] === 'string' && record[locale]) {
|
||||
return record[locale] as string;
|
||||
}
|
||||
|
||||
if (typeof record.de === 'string' && record.de) {
|
||||
return record.de as string;
|
||||
}
|
||||
|
||||
if (typeof record.en === 'string' && record.en) {
|
||||
return record.en as string;
|
||||
}
|
||||
|
||||
const firstValue = Object.values(record).find((value) => typeof value === 'string' && value);
|
||||
return (firstValue as string | undefined) ?? null;
|
||||
}
|
||||
|
||||
export function localizeTaskLabel(
|
||||
raw: string | LocalizedRecord | null | undefined,
|
||||
locale: LocaleCode,
|
||||
): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof raw === 'object') {
|
||||
return pickLocalizedValue(raw, locale);
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return pickLocalizedValue(parsed as LocalizedRecord, locale) ?? trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { Variants } from 'framer-motion';
|
||||
|
||||
export const IOS_EASE = [0.22, 0.61, 0.36, 1] as const;
|
||||
export const IOS_EASE_SOFT = [0.25, 0.8, 0.25, 1] as const;
|
||||
|
||||
export const STAGGER_FAST: Variants = {
|
||||
hidden: {},
|
||||
show: {
|
||||
transition: {
|
||||
staggerChildren: 0.06,
|
||||
delayChildren: 0.04,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FADE_UP: Variants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.24,
|
||||
ease: IOS_EASE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FADE_SCALE: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.98 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.22,
|
||||
ease: IOS_EASE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches);
|
||||
}
|
||||
|
||||
export function getMotionContainerProps(enabled: boolean, variants: Variants) {
|
||||
if (!enabled) {
|
||||
return { initial: false } as const;
|
||||
}
|
||||
|
||||
return { variants, initial: 'hidden', animate: 'show' } as const;
|
||||
}
|
||||
|
||||
export function getMotionItemProps(enabled: boolean, variants: Variants) {
|
||||
return enabled ? { variants } : {};
|
||||
}
|
||||
|
||||
export function getMotionContainerPropsForNavigation(
|
||||
enabled: boolean,
|
||||
variants: Variants,
|
||||
navigationType: 'POP' | 'PUSH' | 'REPLACE'
|
||||
) {
|
||||
if (!enabled) {
|
||||
return { initial: false } as const;
|
||||
}
|
||||
|
||||
const initial = navigationType === 'POP' ? 'hidden' : false;
|
||||
|
||||
return { variants, initial, animate: 'show' } as const;
|
||||
}
|
||||
|
||||
export function getMotionItemPropsForNavigation(
|
||||
enabled: boolean,
|
||||
variants: Variants,
|
||||
navigationType: 'POP' | 'PUSH' | 'REPLACE'
|
||||
) {
|
||||
if (!enabled) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const initial = navigationType === 'POP' ? 'hidden' : false;
|
||||
|
||||
return { variants, initial, animate: 'show' } as const;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
type PushConfig = {
|
||||
enabled: boolean;
|
||||
vapidPublicKey: string | null;
|
||||
};
|
||||
|
||||
type RuntimeConfig = {
|
||||
push: PushConfig;
|
||||
};
|
||||
|
||||
export function getRuntimeConfig(): RuntimeConfig {
|
||||
const raw = typeof window !== 'undefined' ? window.__GUEST_RUNTIME_CONFIG__ : undefined;
|
||||
|
||||
return {
|
||||
push: {
|
||||
enabled: Boolean(raw?.push?.enabled),
|
||||
vapidPublicKey: raw?.push?.vapidPublicKey ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getPushConfig(): PushConfig {
|
||||
return getRuntimeConfig().push;
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { createPhotoShareLink } from '../services/photosApi';
|
||||
|
||||
type ShareOptions = {
|
||||
token: string;
|
||||
photoId: number;
|
||||
title?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore and fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const input = document.createElement('input');
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sharePhotoLink(options: ShareOptions): Promise<{ url: string; method: 'native' | 'clipboard' | 'manual' }>
|
||||
{
|
||||
const payload = await createPhotoShareLink(options.token, options.photoId);
|
||||
const shareData: ShareData = {
|
||||
title: options.title ?? 'Fotospiel Moment',
|
||||
text: options.text ?? '',
|
||||
url: payload.url,
|
||||
};
|
||||
|
||||
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
return { url: payload.url, method: 'native' };
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError') {
|
||||
return { url: payload.url, method: 'native' };
|
||||
}
|
||||
// fall through to clipboard
|
||||
}
|
||||
}
|
||||
|
||||
if (await copyToClipboard(payload.url)) {
|
||||
return { url: payload.url, method: 'clipboard' };
|
||||
}
|
||||
|
||||
return { url: payload.url, method: 'manual' };
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export type TaskIdentity = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export function dedupeTasksById<T extends TaskIdentity>(tasks: T[]): T[] {
|
||||
const seen = new Set<number>();
|
||||
const unique: T[] = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (seen.has(task.id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(task.id);
|
||||
unique.push(task);
|
||||
});
|
||||
|
||||
return unique;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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