weitere verbesserungen der Guest PWA (vor allem TaskPicker)
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Emotion {
|
||||
id: number;
|
||||
@@ -13,9 +14,19 @@ interface Emotion {
|
||||
|
||||
interface EmotionPickerProps {
|
||||
onSelect?: (emotion: Emotion) => void;
|
||||
variant?: 'standalone' | 'embedded';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showSkip?: boolean;
|
||||
}
|
||||
|
||||
export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
||||
export default function EmotionPicker({
|
||||
onSelect,
|
||||
variant = 'standalone',
|
||||
title,
|
||||
subtitle,
|
||||
showSkip,
|
||||
}: EmotionPickerProps) {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const eventKey = token ?? '';
|
||||
const navigate = useNavigate();
|
||||
@@ -73,17 +84,29 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const headingTitle = title ?? 'Wie fühlst du dich?';
|
||||
const headingSubtitle = subtitle ?? '(optional)';
|
||||
const shouldShowSkip = showSkip ?? variant === 'standalone';
|
||||
|
||||
const content = (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">
|
||||
Wie fühlst du dich?
|
||||
<span className="ml-2 text-xs text-muted-foreground">(optional)</span>
|
||||
</h3>
|
||||
{loading && <span className="text-xs text-muted-foreground">Lade Emotionen…</span>}
|
||||
</div>
|
||||
{(variant === 'standalone' || title) && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">
|
||||
{headingTitle}
|
||||
{headingSubtitle && <span className="ml-2 text-xs text-muted-foreground">{headingSubtitle}</span>}
|
||||
</h3>
|
||||
{loading && <span className="text-xs text-muted-foreground">Lade Emotionen…</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none]" aria-label="Emotions">
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 pb-2',
|
||||
variant === 'standalone' ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2 sm:grid-cols-3'
|
||||
)}
|
||||
aria-label="Emotions"
|
||||
>
|
||||
{emotions.map((emotion) => {
|
||||
// Localize name and description if they are JSON
|
||||
const localize = (value: string | object, defaultValue: string = ''): string => {
|
||||
@@ -125,18 +148,20 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
||||
</div>
|
||||
|
||||
{/* Skip option */}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
|
||||
onClick={() => {
|
||||
if (!eventKey) return;
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/tasks`);
|
||||
}}
|
||||
>
|
||||
Überspringen und Aufgabe wählen
|
||||
</Button>
|
||||
</div>
|
||||
{shouldShowSkip && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
|
||||
onClick={() => {
|
||||
if (!eventKey) return;
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/tasks`);
|
||||
}}
|
||||
>
|
||||
Überspringen und Aufgabe wählen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -148,9 +173,9 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-muted/40 bg-gradient-to-br from-white to-white/70 p-4 shadow-sm backdrop-blur">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
if (variant === 'embedded') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <div className="rounded-3xl border border-muted/40 bg-gradient-to-br from-white to-white/70 p-4 shadow-sm backdrop-blur">{content}</div>;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,13 @@ export default function GalleryPreview({ token }: Props) {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Lust auf mehr?{' '}
|
||||
<Link to={`/e/${encodeURIComponent(token)}/gallery`} className="font-semibold text-pink-600 hover:text-pink-700">
|
||||
Zur Galerie →
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { User, Heart, Users, PartyPopper, Camera } from 'lucide-react';
|
||||
import { User, Heart, Users, PartyPopper, Camera, Bell, ArrowUpRight } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||
import { useOptionalNotificationCenter } from '../context/NotificationCenterContext';
|
||||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||
|
||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
heart: Heart,
|
||||
@@ -86,6 +89,31 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||
const primaryForeground = '#ffffff';
|
||||
const { event, status } = useEventData();
|
||||
const notificationCenter = useOptionalNotificationCenter();
|
||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||
const taskProgress = useGuestTaskProgress(eventToken);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const checklistItems = React.useMemo(
|
||||
() => [
|
||||
t('home.checklist.steps.first'),
|
||||
t('home.checklist.steps.second'),
|
||||
t('home.checklist.steps.third'),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!notificationsOpen) {
|
||||
return;
|
||||
}
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (!panelRef.current) return;
|
||||
if (panelRef.current.contains(event.target as Node)) return;
|
||||
setNotificationsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [notificationsOpen]);
|
||||
|
||||
if (!eventToken) {
|
||||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||||
@@ -139,7 +167,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
|
||||
const stats =
|
||||
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
|
||||
@@ -172,6 +199,17 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{notificationCenter && (
|
||||
<NotificationButton
|
||||
eventToken={eventToken}
|
||||
center={notificationCenter}
|
||||
open={notificationsOpen}
|
||||
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||||
panelRef={panelRef}
|
||||
checklistItems={checklistItems}
|
||||
taskProgress={taskProgress?.hydrated ? taskProgress : undefined}
|
||||
/>
|
||||
)}
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
</div>
|
||||
@@ -179,4 +217,101 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
);
|
||||
}
|
||||
|
||||
export {}
|
||||
function NotificationButton({
|
||||
center,
|
||||
eventToken,
|
||||
open,
|
||||
onToggle,
|
||||
panelRef,
|
||||
checklistItems,
|
||||
taskProgress,
|
||||
}: {
|
||||
center: {
|
||||
queueCount: number;
|
||||
inviteCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
eventToken: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
panelRef: React.RefObject<HTMLDivElement>;
|
||||
checklistItems: string[];
|
||||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||||
}) {
|
||||
if (!center) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalCount = center.totalCount;
|
||||
const progressRatio = taskProgress
|
||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
||||
aria-label="Benachrichtigungen anzeigen"
|
||||
>
|
||||
<Bell className="h-5 w-5" aria-hidden />
|
||||
{totalCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
|
||||
{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-0 mt-2 w-72 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-900">Benachrichtigungen</p>
|
||||
<p className="text-xs text-slate-500">Uploads in Warteschlange: {center.queueCount}</p>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(eventToken)}/queue`}
|
||||
className="mt-2 flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold text-pink-600 transition hover:border-pink-300"
|
||||
>
|
||||
Zur Warteschlange
|
||||
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
{taskProgress && (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">Badge-Fortschritt</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
||||
>
|
||||
Weiter
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-pink-500"
|
||||
style={{ width: `${progressRatio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="my-3 h-px w-full bg-slate-100" />
|
||||
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">So funktioniert’s</p>
|
||||
<ul className="mt-2 space-y-2 text-sm text-slate-600">
|
||||
{checklistItems.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-pink-500" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
59
resources/js/guest/context/NotificationCenterContext.tsx
Normal file
59
resources/js/guest/context/NotificationCenterContext.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useUploadQueue } from '../queue/hooks';
|
||||
import type { QueueItem } from '../queue/queue';
|
||||
|
||||
type NotificationCenterValue = {
|
||||
queueItems: QueueItem[];
|
||||
queueCount: number;
|
||||
inviteCount: number;
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
refreshQueue: () => Promise<void>;
|
||||
eventToken: string;
|
||||
};
|
||||
|
||||
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
|
||||
|
||||
export function NotificationCenterProvider({
|
||||
eventToken,
|
||||
children,
|
||||
}: {
|
||||
eventToken: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { items, loading, refresh } = useUploadQueue();
|
||||
|
||||
const queueCount = React.useMemo(
|
||||
() => items.filter((item) => item.status !== 'done').length,
|
||||
[items],
|
||||
);
|
||||
|
||||
const value = React.useMemo<NotificationCenterValue>(
|
||||
() => ({
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
inviteCount: 0,
|
||||
totalCount: queueCount,
|
||||
loading,
|
||||
refreshQueue: refresh,
|
||||
eventToken,
|
||||
}),
|
||||
[items, queueCount, loading, refresh, eventToken],
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationCenterContext.Provider value={value}>{children}</NotificationCenterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotificationCenter() {
|
||||
const ctx = React.useContext(NotificationCenterContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalNotificationCenter() {
|
||||
return React.useContext(NotificationCenterContext);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export const TASK_BADGE_TARGET = 5;
|
||||
|
||||
function storageKey(eventKey: string) {
|
||||
return `guestTasks_${eventKey}`;
|
||||
}
|
||||
|
||||
@@ -184,6 +184,40 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
days: 'vor {count} Tagen',
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
page: {
|
||||
eyebrow: 'Aufgaben-Zentrale',
|
||||
title: 'Deine nächste Aufgabe',
|
||||
subtitle: 'Wähle eine Stimmung aus oder lass dich überraschen.',
|
||||
swipeHint: 'Tipp: Links wischen = neue Aufgabe · Rechts wischen = Inspiration',
|
||||
completedLabel: 'Schon erledigt',
|
||||
ctaStart: "Los geht's!",
|
||||
shuffleCta: 'Was Neues!',
|
||||
shuffleButton: 'Shuffle',
|
||||
inspirationTitle: 'Foto-Inspiration',
|
||||
inspirationLoading: 'lädt…',
|
||||
inspirationEmptyTitle: 'Noch kein Foto zu dieser Aufgabe',
|
||||
inspirationEmptyDescription: 'Sei die/der Erste und lade eins hoch',
|
||||
inspirationMore: 'Mehr',
|
||||
inspirationError: 'Fotos konnten nicht geladen werden',
|
||||
suggestionsEyebrow: 'Mehr Inspiration',
|
||||
suggestionsTitle: 'Spring direkt zur nächsten Aufgabe',
|
||||
noTasksAlert: 'Für dieses Event sind derzeit keine Aufgaben hinterlegt.',
|
||||
emptyTitle: 'Keine passende Aufgabe gefunden',
|
||||
emptyDescriptionWithTasks: 'Für deine aktuelle Stimmung gibt es gerade keine Aufgabe. Wähle eine andere Stimmung oder lade neue Aufgaben.',
|
||||
emptyDescriptionNoTasks: 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es später erneut.',
|
||||
reloadButton: 'Aufgaben neu laden',
|
||||
filters: {
|
||||
none: 'Kein Filter',
|
||||
recentFallback: 'Stimmung wählen',
|
||||
showAll: 'Alle zeigen',
|
||||
dialogTitle: 'Alle verfügbaren Stimmungen',
|
||||
empty: 'Für dieses Event stehen noch keine Stimmungen bereit.',
|
||||
countOne: '{count} Aufgabe',
|
||||
countMany: '{count} Aufgaben',
|
||||
},
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Nicht gefunden',
|
||||
description: 'Die Seite konnte nicht gefunden werden.',
|
||||
@@ -255,6 +289,49 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
dismiss: 'Verstanden',
|
||||
},
|
||||
hud: {
|
||||
title: 'Live-Missionen',
|
||||
subtitle: 'Bleib im Flow – Kamera bereithalten.',
|
||||
moodLabel: 'Stimmung: {mood}',
|
||||
moodFallback: 'Freestyle',
|
||||
ctaLabel: 'Inspiration öffnen',
|
||||
cards: {
|
||||
online: 'Gäste online',
|
||||
completed: 'Aufgaben gelöst',
|
||||
lastUpload: 'Letzter Upload',
|
||||
},
|
||||
progressLabel: 'Story {count}/{target} aktiv',
|
||||
liveGuests: '{count} Gäste live',
|
||||
relative: {
|
||||
now: 'Gerade eben',
|
||||
minutes: 'vor {count} Min',
|
||||
hours: 'vor {count} Std',
|
||||
days: 'vor {count} Tagen',
|
||||
},
|
||||
},
|
||||
limitSummary: {
|
||||
title: 'Uploads & Slots',
|
||||
subtitle: 'Dein Event-Paket im Überblick',
|
||||
badgeLabel: 'Aktuell',
|
||||
cards: {
|
||||
photos: {
|
||||
title: 'Fotos insgesamt',
|
||||
remaining: '{remaining} von {limit} frei',
|
||||
unlimited: 'Unlimitierte Foto-Uploads aktiv',
|
||||
},
|
||||
guests: {
|
||||
title: 'Geräte im Einsatz',
|
||||
remaining: '{remaining} Slots verfügbar',
|
||||
unlimited: 'Unlimitierte Geräte freigeschaltet',
|
||||
},
|
||||
},
|
||||
badges: {
|
||||
ok: 'OK',
|
||||
warning: 'Bald voll',
|
||||
limit_reached: 'Limit erreicht',
|
||||
unlimited: 'Unlimitiert',
|
||||
},
|
||||
},
|
||||
cameraUnsupported: {
|
||||
title: 'Kamera nicht verfügbar',
|
||||
message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
||||
@@ -652,6 +729,40 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
days: '{count} days ago',
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
page: {
|
||||
eyebrow: 'Mission hub',
|
||||
title: 'Your next task',
|
||||
subtitle: 'Pick a mood or stay spontaneous.',
|
||||
swipeHint: 'Tip: Swipe left for a new task · right for inspiration',
|
||||
completedLabel: 'Already done',
|
||||
ctaStart: "Let's go!",
|
||||
shuffleCta: 'Something new!',
|
||||
shuffleButton: 'Shuffle',
|
||||
inspirationTitle: 'Photo inspiration',
|
||||
inspirationLoading: 'loading…',
|
||||
inspirationEmptyTitle: 'No photo for this task yet',
|
||||
inspirationEmptyDescription: 'Be the first one to upload!',
|
||||
inspirationMore: 'More',
|
||||
inspirationError: 'Photos could not be loaded',
|
||||
suggestionsEyebrow: 'More inspiration',
|
||||
suggestionsTitle: 'Jump straight to the next task',
|
||||
noTasksAlert: 'No tasks available for this event yet.',
|
||||
emptyTitle: 'No matching task found',
|
||||
emptyDescriptionWithTasks: 'No task matches this mood right now. Pick another mood or load new tasks.',
|
||||
emptyDescriptionNoTasks: 'No tasks are available yet. Please try again later.',
|
||||
reloadButton: 'Reload tasks',
|
||||
filters: {
|
||||
none: 'No filter',
|
||||
recentFallback: 'Select mood',
|
||||
showAll: 'Show all',
|
||||
dialogTitle: 'All available moods',
|
||||
empty: 'No moods are available for this event yet.',
|
||||
countOne: '{count} task',
|
||||
countMany: '{count} tasks',
|
||||
},
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Not found',
|
||||
description: 'We could not find the page you requested.',
|
||||
@@ -723,6 +834,49 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
dismiss: 'Got it',
|
||||
},
|
||||
hud: {
|
||||
title: 'Live missions',
|
||||
subtitle: 'Stay in the flow – keep the camera ready.',
|
||||
moodLabel: 'Mood: {mood}',
|
||||
moodFallback: 'Freestyle',
|
||||
ctaLabel: 'Open inspiration',
|
||||
cards: {
|
||||
online: 'Guests online',
|
||||
completed: 'Tasks completed',
|
||||
lastUpload: 'Latest upload',
|
||||
},
|
||||
progressLabel: 'Story {count}/{target} active',
|
||||
liveGuests: '{count} guests live',
|
||||
relative: {
|
||||
now: 'Just now',
|
||||
minutes: '{count} min ago',
|
||||
hours: '{count} h ago',
|
||||
days: '{count} days ago',
|
||||
},
|
||||
},
|
||||
limitSummary: {
|
||||
title: 'Uploads & slots',
|
||||
subtitle: 'Your event package overview',
|
||||
badgeLabel: 'Current',
|
||||
cards: {
|
||||
photos: {
|
||||
title: 'Photos total',
|
||||
remaining: '{remaining} of {limit} free',
|
||||
unlimited: 'Unlimited photo uploads',
|
||||
},
|
||||
guests: {
|
||||
title: 'Devices in use',
|
||||
remaining: '{remaining} slots available',
|
||||
unlimited: 'Unlimited devices enabled',
|
||||
},
|
||||
},
|
||||
badges: {
|
||||
ok: 'OK',
|
||||
warning: 'Almost full',
|
||||
limit_reached: 'Limit reached',
|
||||
unlimited: 'Unlimited',
|
||||
},
|
||||
},
|
||||
cameraUnsupported: {
|
||||
title: 'Camera not available',
|
||||
message: 'Your device does not support live camera preview in this browser. You can upload photos from your gallery instead.',
|
||||
|
||||
@@ -4,16 +4,16 @@ 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'],
|
||||
['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;
|
||||
|
||||
@@ -35,13 +35,13 @@ function buildCard(
|
||||
summary: LimitUsageSummary,
|
||||
t: TranslateFn
|
||||
): LimitSummaryCard {
|
||||
const labelKey = id === 'photos' ? 'upload.status.cards.photos.title' : 'upload.status.cards.guests.title';
|
||||
const labelKey = id === 'photos' ? 'upload.limitSummary.cards.photos.title' : 'upload.limitSummary.cards.guests.title';
|
||||
const remainingKey = id === 'photos'
|
||||
? 'upload.status.cards.photos.remaining'
|
||||
: 'upload.status.cards.guests.remaining';
|
||||
? 'upload.limitSummary.cards.photos.remaining'
|
||||
: 'upload.limitSummary.cards.guests.remaining';
|
||||
const unlimitedKey = id === 'photos'
|
||||
? 'upload.status.cards.photos.unlimited'
|
||||
: 'upload.status.cards.guests.unlimited';
|
||||
? 'upload.limitSummary.cards.photos.unlimited'
|
||||
: 'upload.limitSummary.cards.guests.unlimited';
|
||||
|
||||
const tone = resolveTone(summary.state);
|
||||
const progress = typeof summary.limit === 'number' && summary.limit > 0
|
||||
@@ -50,7 +50,7 @@ function buildCard(
|
||||
|
||||
const valueLabel = typeof summary.limit === 'number' && summary.limit > 0
|
||||
? `${summary.used.toLocaleString()} / ${summary.limit.toLocaleString()}`
|
||||
: t('upload.status.badges.unlimited');
|
||||
: t('upload.limitSummary.badges.unlimited');
|
||||
|
||||
const description = summary.state === 'unlimited'
|
||||
? t(unlimitedKey)
|
||||
@@ -63,13 +63,13 @@ function buildCard(
|
||||
const badgeKey = (() => {
|
||||
switch (summary.state) {
|
||||
case 'limit_reached':
|
||||
return 'upload.status.badges.limit_reached';
|
||||
return 'upload.limitSummary.badges.limit_reached';
|
||||
case 'warning':
|
||||
return 'upload.status.badges.warning';
|
||||
return 'upload.limitSummary.badges.warning';
|
||||
case 'unlimited':
|
||||
return 'upload.status.badges.unlimited';
|
||||
return 'upload.limitSummary.badges.unlimited';
|
||||
default:
|
||||
return 'upload.status.badges.ok';
|
||||
return 'upload.limitSummary.badges.ok';
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -269,33 +269,66 @@ function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCards({ data }: { data: AchievementsPayload }) {
|
||||
function FlowSummary({ data, token }: { data: AchievementsPayload; token: string }) {
|
||||
const personal = data.personal;
|
||||
const tasksDone = personal?.tasks ?? data.summary.tasksSolved;
|
||||
const photos = personal?.photos ?? data.summary.totalPhotos;
|
||||
const likes = personal?.likes ?? data.summary.likesTotal;
|
||||
const guests = data.summary.uniqueGuests;
|
||||
const earnedBadges = personal?.badges.filter((badge) => badge.earned).length ?? 0;
|
||||
const nextBadge = personal?.badges.find((badge) => !badge.earned);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Fotos gesamt</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.totalPhotos)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Aktive Gäste</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.uniqueGuests)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Erfüllte Aufgaben</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.tasksSolved)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Likes insgesamt</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.likesTotal)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className="rounded-[32px] border border-slate-100 bg-white/95 p-6 shadow-sm dark:border-white/10 dark:bg-slate-900/70">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-white/60">Dein Flow</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
{tasksDone === 0 ? 'Starte deine erste Mission.' : 'Weiter so, dein Event lebt!'}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-white/70">
|
||||
{nextBadge ? `Noch ${Math.max(0, nextBadge.target - nextBadge.progress)} Schritte bis „${nextBadge.title}“.` : 'Alle aktuellen Badges freigeschaltet.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild>
|
||||
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Aufgabe ziehen
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/e/${encodeURIComponent(token)}/gallery`} className="flex items-center gap-2">
|
||||
<Camera className="h-4 w-4" />
|
||||
Galerie öffnen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FlowStat label="Deine Aufgaben" value={formatNumber(tasksDone)} />
|
||||
<FlowStat label="Fotos gesamt" value={formatNumber(photos)} />
|
||||
<FlowStat label="Likes gesammelt" value={formatNumber(likes)} />
|
||||
<FlowStat label="Badges" value={`${earnedBadges}/${personal?.badges.length ?? 0}`} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<FlowStat label="Aktive Gäste" value={formatNumber(guests)} />
|
||||
<FlowStat label="Letzte Mission" value={personal ? personal.guestName || 'Gast' : 'Event'} muted />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowStat({ label, value, muted = false }: { label: string; value: string; muted?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-slate-100 bg-white/80 px-4 py-3 text-left shadow-sm dark:border-white/10 dark:bg-white/5',
|
||||
muted && 'opacity-80'
|
||||
)}
|
||||
>
|
||||
<p className="text-[0.65rem] uppercase tracking-[0.35em] text-slate-400 dark:text-white/60">{label}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-slate-900 dark:text-white">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -393,7 +426,7 @@ export default function AchievementsPage() {
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<SummaryCards data={data} />
|
||||
<FlowSummary data={data} token={token} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X, Camera, ArrowUpRight } from 'lucide-react';
|
||||
import { Sparkles, UploadCloud, X, Camera, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import type { EventBranding } from '../types/event-branding';
|
||||
@@ -63,69 +63,63 @@ export default function HomePage() {
|
||||
|
||||
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
|
||||
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
|
||||
const latestUploadText = formatLatestUpload(stats.latestPhotoAt, t);
|
||||
const accentColor = branding.primaryColor;
|
||||
const secondaryAccent = branding.secondaryColor;
|
||||
|
||||
const statItems = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <Users className="h-4 w-4" aria-hidden />,
|
||||
label: t('home.stats.online'),
|
||||
value: `${stats.onlineGuests}`,
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-4 w-4" aria-hidden />,
|
||||
label: t('home.stats.tasksSolved'),
|
||||
value: `${stats.tasksSolved}`,
|
||||
},
|
||||
{
|
||||
icon: <TimerReset className="h-4 w-4" aria-hidden />,
|
||||
label: t('home.stats.lastUpload'),
|
||||
value: latestUploadText,
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle2 className="h-4 w-4" aria-hidden />,
|
||||
label: t('home.stats.completedTasks'),
|
||||
value: `${completedCount}`,
|
||||
},
|
||||
],
|
||||
[completedCount, latestUploadText, stats.onlineGuests, stats.tasksSolved, t],
|
||||
);
|
||||
const [missionPreview, setMissionPreview] = React.useState<MissionPreview | null>(null);
|
||||
const [missionLoading, setMissionLoading] = React.useState(false);
|
||||
const missionPoolRef = React.useRef<MissionPreview[]>([]);
|
||||
|
||||
const quickActions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
to: 'upload',
|
||||
label: t('home.actions.items.upload.label'),
|
||||
description: t('home.actions.items.upload.description'),
|
||||
icon: <Camera className="h-5 w-5" aria-hidden />,
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
to: 'tasks',
|
||||
label: t('home.actions.items.tasks.label'),
|
||||
description: t('home.actions.items.tasks.description'),
|
||||
icon: <Sparkles className="h-5 w-5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
to: 'gallery',
|
||||
label: t('home.actions.items.gallery.label'),
|
||||
description: t('home.actions.items.gallery.description'),
|
||||
icon: <Images className="h-5 w-5" aria-hidden />,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const shuffleMissionPreview = React.useCallback(() => {
|
||||
const pool = missionPoolRef.current;
|
||||
if (!pool.length) {
|
||||
setMissionPreview(null);
|
||||
return;
|
||||
}
|
||||
const choice = pool[Math.floor(Math.random() * pool.length)];
|
||||
setMissionPreview(choice);
|
||||
}, []);
|
||||
|
||||
const checklistItems = React.useMemo(
|
||||
() => [
|
||||
t('home.checklist.steps.first'),
|
||||
t('home.checklist.steps.second'),
|
||||
t('home.checklist.steps.third'),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (!token) return;
|
||||
let cancelled = false;
|
||||
async function loadMissions() {
|
||||
setMissionLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(token)}/tasks`);
|
||||
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
||||
const payload = await response.json();
|
||||
if (cancelled) return;
|
||||
if (Array.isArray(payload) && payload.length) {
|
||||
missionPoolRef.current = payload.map((task: any) => ({
|
||||
id: Number(task.id),
|
||||
title: task.title ?? 'Mission',
|
||||
description: task.description ?? '',
|
||||
duration: typeof task.duration === 'number' ? task.duration : 3,
|
||||
emotion: task.emotion ?? null,
|
||||
}));
|
||||
shuffleMissionPreview();
|
||||
} else {
|
||||
missionPoolRef.current = [];
|
||||
setMissionPreview(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.warn('Mission preview failed', err);
|
||||
missionPoolRef.current = [];
|
||||
setMissionPreview(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setMissionLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadMissions();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [shuffleMissionPreview, token]);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -146,55 +140,28 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<StatsRibbon items={statItems} accentColor={accentColor} fontFamily={branding.fontFamily} />
|
||||
|
||||
<section className="space-y-4" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">{t('home.actions.title')}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</p>
|
||||
<h2 className="text-base font-semibold text-foreground">Starte dein Fotospiel</h2>
|
||||
<p className="text-xs text-muted-foreground">Wähle, wie du den nächsten Moment einfängst.</p>
|
||||
</div>
|
||||
<ArrowUpRight className="h-5 w-5 text-muted-foreground" aria-hidden />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{quickActions.map((action) => (
|
||||
<QuickActionCard
|
||||
key={action.to}
|
||||
action={action}
|
||||
accentColor={accentColor}
|
||||
secondaryAccent={secondaryAccent}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="space-y-3">
|
||||
<MissionActionCard
|
||||
token={token}
|
||||
mission={missionPreview}
|
||||
loading={missionLoading}
|
||||
onShuffle={shuffleMissionPreview}
|
||||
/>
|
||||
<EmotionActionCard />
|
||||
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="w-full touch-manipulation border-dashed"
|
||||
style={{ borderColor: `${accentColor}44` }}
|
||||
>
|
||||
<Link to="queue">{t('home.actions.queueButton')}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||||
<CardTitle>{t('home.checklist.title')}</CardTitle>
|
||||
<CardDescription>{t('home.checklist.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{checklistItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" aria-hidden />
|
||||
<span className="text-sm leading-relaxed text-muted-foreground">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<EmotionPicker />
|
||||
|
||||
<GalleryPreview token={token} />
|
||||
</div>
|
||||
);
|
||||
@@ -265,112 +232,119 @@ function HeroCard({
|
||||
);
|
||||
}
|
||||
|
||||
function StatsRibbon({
|
||||
items,
|
||||
accentColor,
|
||||
fontFamily,
|
||||
type MissionPreview = {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
emotion?: { name?: string; slug?: string } | null;
|
||||
};
|
||||
|
||||
function MissionActionCard({
|
||||
token,
|
||||
mission,
|
||||
loading,
|
||||
onShuffle,
|
||||
}: {
|
||||
items: { icon: React.ReactNode; label: string; value: string }[];
|
||||
accentColor: string;
|
||||
fontFamily?: string | null;
|
||||
token: string;
|
||||
mission: MissionPreview | null;
|
||||
loading: boolean;
|
||||
onShuffle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-muted/40 bg-white/70 shadow-sm backdrop-blur">
|
||||
<div
|
||||
className="flex gap-3 overflow-x-auto px-4 py-3 [scrollbar-width:none] sm:grid sm:grid-cols-4 sm:overflow-visible"
|
||||
style={fontFamily ? { fontFamily } : undefined}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex min-w-[150px] flex-1 items-center gap-3 rounded-2xl border border-transparent bg-white/60 px-3 py-2 shadow-sm transition hover:-translate-y-0.5 hover:border-white"
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-pink-100 text-pink-600">
|
||||
<Sparkles className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-foreground">Mission starten</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
Wir haben bereits eine Aufgabe für dich vorbereitet. Tippe, um direkt loszulegen.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{mission ? (
|
||||
<>
|
||||
<p className="text-lg font-semibold text-foreground">{mission.title}</p>
|
||||
{mission.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{mission.description}</p>
|
||||
)}
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<span>{mission.duration ?? 3} Min</span>
|
||||
{mission.emotion?.name && <span>{mission.emotion.name}</span>}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ziehe deine erste Mission im Aufgaben-Tab oder lade deine Stimmung hoch.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button asChild className="flex-1">
|
||||
<Link to={`/e/${encodeURIComponent(token)}/tasks`}>Mission starten</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex-1"
|
||||
onClick={onShuffle}
|
||||
disabled={loading}
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow"
|
||||
style={{ color: accentColor }}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{item.label}</span>
|
||||
<span className="text-lg font-semibold text-foreground">{item.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
|
||||
Andere Mission
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActionCard({
|
||||
action,
|
||||
function EmotionActionCard() {
|
||||
return (
|
||||
<Card className="border border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Foto nach Gefühlslage</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
Wähle deine Stimmung, wir schlagen dir passende Missionen vor.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmotionPicker variant="embedded" showSkip={false} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadActionCard({
|
||||
token,
|
||||
accentColor,
|
||||
secondaryAccent,
|
||||
}: {
|
||||
action: { to: string; label: string; description: string; icon: React.ReactNode; highlight?: boolean };
|
||||
token: string;
|
||||
accentColor: string;
|
||||
secondaryAccent: string;
|
||||
}) {
|
||||
const highlightStyle = action.highlight
|
||||
? {
|
||||
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
|
||||
color: '#fff',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Link to={action.to} className="group block">
|
||||
<Card
|
||||
className="relative overflow-hidden border-0 shadow-sm transition-all group-hover:shadow-lg"
|
||||
style={highlightStyle}
|
||||
>
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-2xl shadow-sm ${
|
||||
action.highlight ? 'bg-white/15 text-white' : 'bg-pink-50'
|
||||
}`}
|
||||
style={!action.highlight ? { color: secondaryAccent } : undefined}
|
||||
>
|
||||
{action.icon}
|
||||
<Card
|
||||
className="overflow-hidden border-0 text-white shadow-sm"
|
||||
style={{ background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})` }}
|
||||
>
|
||||
<CardContent className="flex flex-col gap-3 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl bg-white/15 p-3">
|
||||
<UploadCloud className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className={`text-base font-semibold ${action.highlight ? 'text-white' : 'text-foreground'}`}>
|
||||
{action.label}
|
||||
</span>
|
||||
<span className={`text-sm ${action.highlight ? 'text-white/80' : 'text-muted-foreground'}`}>
|
||||
{action.description}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-lg font-semibold">Direkt hochladen</p>
|
||||
<p className="text-sm text-white/80">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
className={`h-4 w-4 transition ${action.highlight ? 'text-white/70 group-hover:translate-x-0.5 group-hover:-translate-y-0.5' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
<Button asChild className="bg-white/90 text-slate-900 hover:bg-white">
|
||||
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
|
||||
if (!isoDate) {
|
||||
return t('home.latestUpload.none');
|
||||
}
|
||||
const date = new Date(isoDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return t('home.latestUpload.invalid');
|
||||
}
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.round(diffMs / 60000);
|
||||
if (diffMinutes < 1) {
|
||||
return t('home.latestUpload.justNow');
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return t('home.latestUpload.minutes').replace('{count}', `${diffMinutes}`);
|
||||
}
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return t('home.latestUpload.hours').replace('{count}', `${diffHours}`);
|
||||
}
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return t('home.latestUpload.days').replace('{count}', `${diffDays}`);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,9 +29,10 @@ import {
|
||||
ZapOff,
|
||||
} from 'lucide-react';
|
||||
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
|
||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -102,13 +103,15 @@ const LIMIT_CARD_STYLES: Record<LimitSummaryCard['tone'], { card: string; badge:
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export default function UploadPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const eventKey = token ?? '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { markCompleted } = useGuestTaskProgress(token);
|
||||
const { markCompleted, completedCount } = useGuestTaskProgress(token);
|
||||
const { t } = useTranslation();
|
||||
const stats = useEventStats();
|
||||
|
||||
const taskIdParam = searchParams.get('task');
|
||||
const emotionSlug = searchParams.get('emotion') || '';
|
||||
@@ -605,6 +608,11 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
reader.readAsDataURL(file);
|
||||
}, [canUpload, t]);
|
||||
|
||||
const handleOpenInspiration = useCallback(() => {
|
||||
if (!eventKey) return;
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/gallery`);
|
||||
}, [eventKey, navigate]);
|
||||
|
||||
const difficultyBadgeClass = useMemo(() => {
|
||||
if (!task) return 'text-white';
|
||||
switch (task.difficulty) {
|
||||
@@ -620,6 +628,10 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
|
||||
const showTaskOverlay = task && mode !== 'uploading';
|
||||
|
||||
const relativeLastUpload = useMemo(
|
||||
() => formatRelativeTimeLabel(stats.latestPhotoAt, t),
|
||||
[stats.latestPhotoAt, t],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
resetCountdownTimer();
|
||||
@@ -628,24 +640,31 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
}
|
||||
}, [resetCountdownTimer]);
|
||||
|
||||
const heroEmotion = task?.emotion?.name ?? t('upload.hud.moodFallback');
|
||||
|
||||
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>
|
||||
<section className="space-y-4 rounded-[28px] border border-white/20 bg-white/80 p-5 shadow-lg backdrop-blur dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-white/60">
|
||||
{t('upload.limitSummary.title')}
|
||||
</p>
|
||||
<p className="text-base font-semibold text-slate-900 dark:text-white">
|
||||
{t('upload.limitSummary.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="rounded-full bg-black/5 text-xs text-slate-700 dark:bg-white/10 dark:text-white">
|
||||
{t('upload.limitSummary.badgeLabel')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-4 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',
|
||||
'rounded-2xl border p-4 shadow-sm transition-colors',
|
||||
styles.card
|
||||
)}
|
||||
>
|
||||
@@ -676,14 +695,6 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
</section>
|
||||
) : null;
|
||||
|
||||
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className={mainClassName}>{content}</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
|
||||
const dialogToneIconClass: Record<Exclude<UploadErrorDialog['tone'], undefined>, string> = {
|
||||
danger: 'text-rose-500',
|
||||
warning: 'text-amber-500',
|
||||
@@ -714,12 +725,18 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const renderWithDialog = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
||||
<>
|
||||
{renderPage(content, mainClassName)}
|
||||
{errorDialogNode}
|
||||
</>
|
||||
);
|
||||
const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => (
|
||||
<>
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4">
|
||||
<div className={wrapperClassName}>{content}</div>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
{errorDialogNode}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!supportsCamera && !task) {
|
||||
return renderWithDialog(
|
||||
@@ -759,7 +776,7 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
|
||||
const renderPrimer = () => (
|
||||
showPrimer && (
|
||||
<div className="mx-4 mt-3 rounded-xl border border-pink-200 bg-white/90 p-4 text-sm text-pink-900 shadow">
|
||||
<div className="rounded-[28px] border border-pink-200/60 bg-white/90 p-4 text-sm text-pink-900 shadow-lg dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="text-left">
|
||||
@@ -781,14 +798,14 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
if (permissionState === 'granted') return null;
|
||||
if (permissionState === 'unsupported') {
|
||||
return (
|
||||
<Alert className="mx-4">
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
<Alert className="rounded-[24px] border border-amber-200 bg-amber-50/70 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/10 dark:text-amber-50">
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (permissionState === 'denied' || permissionState === 'error') {
|
||||
return (
|
||||
<Alert variant="destructive" className="mx-4">
|
||||
<Alert variant="destructive" className="rounded-[24px] border border-rose-200 bg-rose-50/80 text-rose-900 dark:border-rose-500/40 dark:bg-rose-500/15 dark:text-rose-50">
|
||||
<AlertDescription className="space-y-3">
|
||||
<div>{permissionMessage}</div>
|
||||
<Button size="sm" variant="outline" onClick={startCamera}>
|
||||
@@ -799,22 +816,15 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Alert className="mx-4">
|
||||
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
|
||||
</Alert>
|
||||
<Alert className="rounded-[24px] border border-slate-200 bg-white/80 text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-white">
|
||||
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
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">
|
||||
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-black text-white shadow-2xl">
|
||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -1027,7 +1037,11 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
||||
{limitStatusSection}
|
||||
{renderPrimer()}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -1042,6 +1056,29 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</>
|
||||
,
|
||||
'relative flex flex-col gap-4 pb-4'
|
||||
'space-y-6 pb-[140px]'
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeTimeLabel(value: string | null | undefined, t: TranslateFn): string {
|
||||
if (!value) {
|
||||
return t('upload.hud.relative.now');
|
||||
}
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return t('upload.hud.relative.now');
|
||||
}
|
||||
const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000));
|
||||
if (diffMinutes < 1) {
|
||||
return t('upload.hud.relative.now');
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return t('upload.hud.relative.minutes').replace('{count}', `${diffMinutes}`);
|
||||
}
|
||||
const hours = Math.round(diffMinutes / 60);
|
||||
if (hours < 24) {
|
||||
return t('upload.hud.relative.hours').replace('{count}', `${hours}`);
|
||||
}
|
||||
const days = Math.round(hours / 24);
|
||||
return t('upload.hud.relative.days').replace('{count}', `${days}`);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
|
||||
import { useTranslation, type TranslateFn } from './i18n/useTranslation';
|
||||
import type { EventBranding } from './types/event-branding';
|
||||
import type { EventBrandingPayload, FetchEventErrorCode } from './services/eventApi';
|
||||
import { NotificationCenterProvider } from './context/NotificationCenterContext';
|
||||
|
||||
const LandingPage = React.lazy(() => import('./pages/LandingPage'));
|
||||
const ProfileSetupPage = React.lazy(() => import('./pages/ProfileSetupPage'));
|
||||
@@ -109,13 +110,15 @@ function EventBoundary({ token }: { token: string }) {
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventBrandingProvider branding={branding}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-16">
|
||||
<Header eventToken={token} />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
<NotificationCenterProvider eventToken={token}>
|
||||
<div className="pb-16">
|
||||
<Header eventToken={token} />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
</NotificationCenterProvider>
|
||||
</EventStatsProvider>
|
||||
</EventBrandingProvider>
|
||||
</LocaleProvider>
|
||||
@@ -134,10 +137,12 @@ function SetupLayout() {
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventBrandingProvider branding={branding}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-0">
|
||||
<Header eventToken={token} />
|
||||
<Outlet />
|
||||
</div>
|
||||
<NotificationCenterProvider eventToken={token}>
|
||||
<div className="pb-0">
|
||||
<Header eventToken={token} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</NotificationCenterProvider>
|
||||
</EventStatsProvider>
|
||||
</EventBrandingProvider>
|
||||
</LocaleProvider>
|
||||
|
||||
Reference in New Issue
Block a user