Files
fotospiel-app/resources/js/guest/components/EmotionPicker.tsx
Codex Agent 4235eda49a
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix guest PWA dark mode contrast
2026-01-22 15:47:26 +01:00

195 lines
6.9 KiB
TypeScript

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';
import { useTranslation } from '../i18n/useTranslation';
interface Emotion {
id: number;
slug: string;
name: string;
emoji: string;
description?: string;
}
interface EmotionPickerProps {
onSelect?: (emotion: Emotion) => void;
variant?: 'standalone' | 'embedded';
title?: string;
subtitle?: string;
showSkip?: boolean;
}
export default function EmotionPicker({
onSelect,
variant = 'standalone',
title,
subtitle,
showSkip,
}: EmotionPickerProps) {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const [emotions, setEmotions] = useState<Emotion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { locale } = useTranslation();
// Fallback emotions (when API not available yet)
const fallbackEmotions = React.useMemo<Emotion[]>(
() => [
{ id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' },
{ id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' },
{ id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' },
{ id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' },
{ id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' },
{ id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' },
],
[]
);
useEffect(() => {
if (!eventKey) return;
async function fetchEmotions() {
try {
setLoading(true);
setError(null);
// Try API first
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/emotions?locale=${encodeURIComponent(locale)}`, {
headers: {
Accept: 'application/json',
'X-Locale': locale,
},
});
if (response.ok) {
const data = await response.json();
setEmotions(Array.isArray(data) ? data : fallbackEmotions);
} else {
// Fallback to predefined emotions
console.warn('Emotions API not available, using fallback');
setEmotions(fallbackEmotions);
}
} catch (err) {
console.error('Failed to fetch emotions:', err);
setError('Emotions konnten nicht geladen werden');
setEmotions(fallbackEmotions);
} finally {
setLoading(false);
}
}
fetchEmotions();
}, [eventKey, locale, fallbackEmotions]);
const handleEmotionSelect = (emotion: Emotion) => {
if (onSelect) {
onSelect(emotion);
} else {
// Default: Navigate to tasks with emotion filter
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/tasks?emotion=${emotion.slug}`);
}
};
const headingTitle = title ?? 'Wie fühlst du dich?';
const headingSubtitle = subtitle ?? '(optional)';
const shouldShowSkip = showSkip ?? variant === 'standalone';
const content = (
<div className="space-y-4">
{(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 dark:text-white/70">{headingSubtitle}</span>}
</h3>
{loading && <span className="text-xs text-muted-foreground dark:text-white/70">Lade Emotionen</span>}
</div>
)}
<div className="relative">
<div
className={cn(
'grid grid-rows-2 grid-flow-col auto-cols-[170px] sm:auto-cols-[190px] gap-3 overflow-x-auto pb-2 pr-12',
'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent'
)}
aria-label="Emotions"
>
{emotions.map((emotion) => {
// Localize name and description if they are JSON
const localize = (value: string | object, defaultValue: string = ''): string => {
if (typeof value === 'string' && value.startsWith('{')) {
try {
const data = JSON.parse(value as string);
return data.de || data.en || defaultValue || '';
} catch {
return value as string;
}
}
return value as string;
};
const localizedName = localize(emotion.name, emotion.name);
const localizedDescription = localize(emotion.description || '', '');
return (
<button
key={emotion.id}
type="button"
onClick={() => handleEmotionSelect(emotion)}
className="group flex flex-col gap-2 rounded-2xl border border-muted/40 bg-white/80 px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900/70"
>
<div className="flex items-center gap-3">
<span className="text-2xl" aria-hidden>
{emotion.emoji}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-foreground dark:text-white">{localizedName}</div>
{localizedDescription && (
<div className="text-xs text-muted-foreground line-clamp-2 dark:text-white/60">{localizedDescription}</div>
)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100 dark:text-white/60" />
</div>
</button>
);
})}
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-[var(--guest-background)] via-[var(--guest-background)]/90 to-transparent dark:from-black dark:via-black/80" aria-hidden />
</div>
{/* Skip option */}
{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>
);
if (error) {
return (
<div className="rounded-3xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{error}
</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>;
}