weitere verbesserungen der Guest PWA (vor allem TaskPicker)

This commit is contained in:
Codex Agent
2025-11-12 13:19:28 +01:00
parent 1cec116933
commit d91108c883
20 changed files with 2306 additions and 653 deletions

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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 funktionierts</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>
);
}