Files
fotospiel-app/resources/js/guest/pages/HomePage.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

1003 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { AnimatePresence, motion } from 'framer-motion';
import EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { ArrowLeft, ArrowRight, Camera, ChevronDown, Sparkles, UploadCloud, X, RefreshCw, Timer } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding';
import { Swiper, SwiperSlide } from 'swiper/react';
import { EffectCards } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-cards';
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
import { useDirectUpload } from '../hooks/useDirectUpload';
import { useNavigate } from 'react-router-dom';
import { isTaskModeEnabled } from '../lib/engagement';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
export default function HomePage() {
const { token } = useParams<{ token: string }>();
const { name, hydrated } = useGuestIdentity();
const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(token ?? '');
const { t, locale } = useTranslation();
const { branding } = useEventBranding();
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const radius = branding.buttons?.radius ?? 12;
const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed';
const [heroVisible, setHeroVisible] = React.useState(false);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
const stored = window.sessionStorage.getItem(heroStorageKey);
// standardmäßig versteckt, nur sichtbar falls explizit gesetzt (kann später wieder aktiviert werden)
setHeroVisible(stored === 'show');
} catch {
setHeroVisible(false);
}
}, [heroStorageKey]);
const dismissHero = React.useCallback(() => {
setHeroVisible(false);
if (typeof window === 'undefined') {
return;
}
try {
window.sessionStorage.setItem(heroStorageKey, '1');
} catch {
// ignore storage exceptions (e.g. private mode)
}
}, [heroStorageKey]);
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor;
const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
const tasksEnabled = isTaskModeEnabled(event);
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
const [missionLoading, setMissionLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [hasMore, setHasMore] = React.useState(true);
const [page, setPage] = React.useState(1);
const [swipeCount, setSwipeCount] = React.useState(0);
const seenIdsRef = React.useRef<Set<number>>(new Set());
const poolIndexRef = React.useRef(0);
const sliderStateKey = token ? `missionSliderIndex:${token}` : null;
const swiperRef = React.useRef<any>(null);
const advanceDeck = React.useCallback(() => {
if (swiperRef.current) {
swiperRef.current.slideNext();
}
}, []);
const normalizeTasks = React.useCallback(
(tasks: Record<string, unknown>[]): MissionPreview[] =>
tasks.map((task) => ({
id: Number(task.id),
title: typeof task.title === 'string' ? task.title : 'Mission',
description: typeof task.description === 'string' ? task.description : '',
duration: typeof task.duration === 'number' ? task.duration : 3,
emotion: (task.emotion as EmotionIdentity) ?? null,
})),
[]
);
const mergeIntoPool = React.useCallback(
(incoming: MissionPreview[]) => {
const slugTitle = (title: string) =>
title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
setMissionPool((prev) => {
const byId = new Map<number, MissionPreview>();
const byTitle = new Map<string, MissionPreview>();
const addCandidate = (candidate: MissionPreview) => {
if (byId.has(candidate.id)) return;
const titleKey = slugTitle(candidate.title);
if (byTitle.has(titleKey)) return;
byId.set(candidate.id, candidate);
byTitle.set(titleKey, candidate);
};
prev.forEach(addCandidate);
incoming.forEach(addCandidate);
return Array.from(byId.values());
});
},
[]
);
const fetchTasksPage = React.useCallback(
async (pageToFetch: number, isInitial: boolean = false) => {
if (!token) return;
if (isInitial) {
setMissionLoading(true);
}
setIsLoadingMore(true);
try {
const perPage = 20;
const response = await fetch(
`/api/v1/events/${encodeURIComponent(token)}/tasks?page=${pageToFetch}&per_page=${perPage}&locale=${encodeURIComponent(locale)}`,
{
headers: {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
},
}
);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
let items: Record<string, unknown>[] = [];
let hasMoreFlag = false;
let nextPage = pageToFetch + 1;
if (Array.isArray(payload)) {
items = payload;
hasMoreFlag = false;
} else if (payload && Array.isArray(payload.tasks)) {
items = payload.tasks;
hasMoreFlag = false;
} else if (payload && Array.isArray(payload.data)) {
items = payload.data;
if (payload.meta?.current_page && payload.meta?.last_page) {
hasMoreFlag = payload.meta.current_page < payload.meta.last_page;
nextPage = (payload.meta.current_page as number) + 1;
} else if (payload.next_page_url !== undefined) {
hasMoreFlag = Boolean(payload.next_page_url);
} else {
hasMoreFlag = items.length > 0;
}
} else {
hasMoreFlag = false;
}
const normalized = normalizeTasks(items);
const deduped = normalized.filter((task) => {
if (seenIdsRef.current.has(task.id)) return false;
seenIdsRef.current.add(task.id);
return true;
});
if (deduped.length) {
mergeIntoPool(deduped);
}
setHasMore(hasMoreFlag);
if (hasMoreFlag) {
setPage(nextPage);
}
} catch (err) {
console.warn('Mission fetch failed', err);
setHasMore(false);
} finally {
setIsLoadingMore(false);
if (isInitial) {
setMissionLoading(false);
}
}
},
[locale, normalizeTasks, token]
);
React.useEffect(() => {
if (!token) return;
seenIdsRef.current = new Set();
setMissionDeck([]);
setMissionPool([]);
setPage(1);
setHasMore(true);
// restore persisted slider position for this event
let restoredIndex = 0;
if (sliderStateKey && typeof window !== 'undefined') {
try {
const stored = window.sessionStorage.getItem(sliderStateKey);
if (stored) {
const parsed = Number(stored);
if (!Number.isNaN(parsed) && parsed >= 0) {
restoredIndex = parsed;
}
}
} catch {
restoredIndex = 0;
}
}
poolIndexRef.current = restoredIndex;
if (!tasksEnabled) return;
fetchTasksPage(1, true);
}, [fetchTasksPage, locale, sliderStateKey, tasksEnabled, token]);
React.useEffect(() => {
if (missionPool.length === 0) return;
if (poolIndexRef.current >= missionPool.length) {
poolIndexRef.current = poolIndexRef.current % missionPool.length;
}
setMissionDeck(missionPool);
}, [missionPool]);
React.useEffect(() => {
if (!swiperRef.current) return;
if (!missionDeck.length) return;
const target = poolIndexRef.current % missionDeck.length;
const inst = swiperRef.current;
if (typeof inst.slideToLoop === 'function') {
inst.slideToLoop(target, 0);
} else if (typeof inst.slideTo === 'function') {
inst.slideTo(target, 0);
}
}, [missionDeck.length]);
React.useEffect(() => {
if (missionLoading) return;
if (!hasMore || isLoadingMore) return;
// Prefetch when we are within 6 items of the end of the current pool
const remaining = missionPool.length - poolIndexRef.current;
if (remaining <= 6) {
fetchTasksPage(page);
}
}, [fetchTasksPage, hasMore, isLoadingMore, missionLoading, missionPool.length, page]);
if (!token) {
return null;
}
const introArray: string[] = [];
for (let i = 0; i < 12; i += 1) {
const candidate = t(`home.introRotating.${i}`, '');
if (candidate) {
introArray.push(candidate);
}
}
const introMessageRef = React.useRef<string | null>(null);
if (!introMessageRef.current) {
introMessageRef.current =
introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : '';
}
const introMessage = introMessageRef.current;
if (!tasksEnabled) {
return (
<motion.div className="space-y-3 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
<motion.section
className="space-y-1 px-4"
style={headingFont ? { fontFamily: headingFont } : undefined}
{...fadeUpMotion}
>
<p className="text-sm font-semibold text-foreground">
{t('home.welcomeLine').replace('{name}', displayName)}
</p>
{introMessage && <p className="text-xs text-muted-foreground">{introMessage}</p>}
</motion.section>
<motion.section className="space-y-2 px-4" {...fadeUpMotion}>
<UploadActionCard
token={token}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
radius={radius}
bodyFont={bodyFont}
requiresApproval={uploadsRequireApproval}
/>
</motion.section>
<motion.div {...fadeUpMotion}>
<Separator />
</motion.div>
<motion.div {...fadeUpMotion}>
<GalleryPreview token={token} />
</motion.div>
</motion.div>
);
}
return (
<motion.div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
<motion.section
className="space-y-1 px-4"
style={headingFont ? { fontFamily: headingFont } : undefined}
{...fadeUpMotion}
>
<p className="text-sm font-semibold text-foreground">
{t('home.welcomeLine').replace('{name}', displayName)}
</p>
{introMessage && (
<p className="text-xs text-muted-foreground">
{introMessage}
</p>
)}
</motion.section>
{heroVisible && (
<motion.div {...fadeScaleMotion}>
<HeroCard
name={displayName}
eventName={eventNameDisplay}
tasksCompleted={completedCount}
t={t}
branding={branding}
onDismiss={dismissHero}
ctaLabel={t('home.actions.items.tasks.label')}
ctaHref={`/e/${encodeURIComponent(token)}/tasks`}
/>
</motion.div>
)}
<motion.section className="space-y-0.5" style={headingFont ? { fontFamily: headingFont } : undefined} {...fadeUpMotion}>
<div className="space-y-0.5">
<MissionActionCard
token={token}
mission={missionDeck[0] ?? null}
loading={missionLoading}
onAdvance={advanceDeck}
stack={missionDeck.slice(1)}
initialIndex={poolIndexRef.current}
swipeHintLabel={t('home.swipeHint')}
onIndexChange={(idx, total) => {
poolIndexRef.current = idx % Math.max(1, total);
if (sliderStateKey && typeof window !== 'undefined') {
try {
window.sessionStorage.setItem(sliderStateKey, String(poolIndexRef.current));
} catch {
// ignore storage errors
}
}
}}
swiperRef={swiperRef}
/>
<UploadActionCard
token={token}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
radius={radius}
bodyFont={bodyFont}
requiresApproval={uploadsRequireApproval}
/>
<EmotionActionCard />
</div>
</motion.section>
<motion.div {...fadeUpMotion}>
<Separator />
</motion.div>
<motion.div {...fadeUpMotion}>
<GalleryPreview token={token} />
</motion.div>
</motion.div>
);
}
function HeroCard({
name,
eventName,
tasksCompleted,
t,
branding,
onDismiss,
ctaLabel,
ctaHref,
}: {
name: string;
eventName: string;
tasksCompleted: number;
t: TranslateFn;
branding: EventBranding;
onDismiss: () => void;
ctaLabel?: string;
ctaHref?: string;
}) {
const heroTitle = t('home.hero.title').replace('{name}', name);
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
const progressMessage = tasksCompleted > 0
? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`)
: t('home.hero.progress.none');
const style = React.useMemo(() => ({
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
color: '#ffffff',
fontFamily: branding.fontFamily ?? undefined,
}), [branding.fontFamily, branding.primaryColor, branding.secondaryColor]);
return (
<Card className="relative overflow-hidden border-0 text-white shadow-md" style={style}>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/20 text-white hover:bg-white/30"
onClick={onDismiss}
>
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">{t('common.actions.close')}</span>
</Button>
<CardHeader className="space-y-4 pr-10">
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
<CardTitle className="text-2xl font-bold leading-snug">{heroTitle}</CardTitle>
<p className="text-sm text-white/85">{heroDescription}</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
{ctaHref && ctaLabel && (
<Button
variant="secondary"
size="sm"
asChild
className="rounded-full bg-white/15 px-4 py-1 text-xs font-semibold uppercase tracking-wide text-white hover:bg-white/25"
>
<Link to={ctaHref}>{ctaLabel}</Link>
</Button>
)}
</div>
</CardHeader>
</Card>
);
}
type MissionPreview = {
id: number;
title: string;
description?: string;
duration?: number;
emotion?: EmotionIdentity | null;
};
export function MissionActionCard({
token,
mission,
loading,
onAdvance,
stack,
initialIndex,
onIndexChange,
swiperRef,
swipeHintLabel,
}: {
token: string;
mission: MissionPreview | null;
loading: boolean;
onAdvance: () => void;
stack: MissionPreview[];
initialIndex: number;
onIndexChange: (index: number, total: number) => void;
swiperRef: React.MutableRefObject<any>;
swipeHintLabel?: string;
}) {
const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12;
const primary = branding.buttons?.primary ?? branding.primaryColor;
const secondary = branding.buttons?.secondary ?? branding.secondaryColor;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const cards = mission ? [mission, ...stack] : stack;
const shellRadius = `${radius + 10}px`;
const normalizeText = (value: string | undefined | null) =>
(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
const [expandedTaskId, setExpandedTaskId] = React.useState<number | null>(null);
const lastSlideIndexRef = React.useRef<number>(initialIndex);
const titleRefs = React.useRef(new Map<number, HTMLParagraphElement | null>());
const [expandableTitles, setExpandableTitles] = React.useState<Record<number, boolean>>({});
const [showSwipeHint, setShowSwipeHint] = React.useState(false);
const hintTimeoutRef = React.useRef<number | null>(null);
const hintStorageKey = token ? `guestMissionSwipeHintSeen_${token}` : 'guestMissionSwipeHintSeen';
const motionEnabled = !prefersReducedMotion();
const shouldShowHint = motionEnabled && cards.length > 1 && Boolean(swipeHintLabel);
const dismissSwipeHint = React.useCallback((persist = true) => {
if (!showSwipeHint) {
return;
}
setShowSwipeHint(false);
if (hintTimeoutRef.current) {
window.clearTimeout(hintTimeoutRef.current);
hintTimeoutRef.current = null;
}
if (persist && typeof window !== 'undefined') {
try {
window.sessionStorage.setItem(hintStorageKey, '1');
} catch {
// ignore storage exceptions
}
}
}, [hintStorageKey, showSwipeHint]);
React.useEffect(() => {
if (!shouldShowHint) {
setShowSwipeHint(false);
return;
}
if (typeof window === 'undefined') {
return;
}
try {
if (window.sessionStorage.getItem(hintStorageKey)) {
setShowSwipeHint(false);
return;
}
} catch {
// ignore storage exceptions
}
setShowSwipeHint(true);
hintTimeoutRef.current = window.setTimeout(() => {
setShowSwipeHint(false);
try {
window.sessionStorage.setItem(hintStorageKey, '1');
} catch {
// ignore storage exceptions
}
}, 3200);
return () => {
if (hintTimeoutRef.current) {
window.clearTimeout(hintTimeoutRef.current);
hintTimeoutRef.current = null;
}
};
}, [hintStorageKey, shouldShowHint]);
const measureTitleOverflow = React.useCallback(() => {
setExpandableTitles((prev) => {
let hasChange = false;
const next = { ...prev };
titleRefs.current.forEach((element, id) => {
if (!element || element.dataset.collapsed !== 'true') {
return;
}
const isOverflowing = element.scrollHeight > element.clientHeight + 1;
if (next[id] !== isOverflowing) {
next[id] = isOverflowing;
hasChange = true;
}
});
return hasChange ? next : prev;
});
}, []);
React.useLayoutEffect(() => {
if (typeof window === 'undefined') {
return;
}
const raf = window.requestAnimationFrame(measureTitleOverflow);
return () => window.cancelAnimationFrame(raf);
}, [measureTitleOverflow, cards, headingFont]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleResize = () => {
window.requestAnimationFrame(measureTitleOverflow);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [measureTitleOverflow]);
const renderCardContent = (card: MissionPreview | null) => {
const theme = getEmotionTheme(card?.emotion ?? null);
const emotionIcon = getEmotionIcon(card?.emotion ?? null);
const durationMinutes = card?.duration ?? 3;
const titleFont = headingFont ? { fontFamily: headingFont } : undefined;
const gradientBackground = card ? theme.gradientBackground : `linear-gradient(135deg, ${primary}, ${secondary})`;
const isExpanded = card ? expandedTaskId === card.id : false;
const isExpandable = Boolean(card && expandableTitles[card.id]);
const titleClamp = isExpanded ? '' : 'line-clamp-2 sm:line-clamp-3';
const titleClasses = `text-xl font-semibold leading-snug text-slate-900 dark:text-white sm:text-2xl break-words py-1 min-h-[3.75rem] sm:min-h-[4.5rem] ${titleClamp}`;
const titleId = card ? `task-title-${card.id}` : undefined;
const toggleExpanded = () => {
if (!card) return;
setExpandedTaskId((prev) => (prev === card.id ? null : card.id));
};
return (
<div
className="relative isolate overflow-hidden"
style={{
borderRadius: shellRadius,
background: gradientBackground,
boxShadow: `0 18px 50px ${primary}35, 0 6px 18px ${primary}22`,
fontFamily: bodyFont,
}}
>
<div
className="absolute inset-0 opacity-25"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0.18) 1px, transparent 1px), linear-gradient(0deg, rgba(255,255,255,0.18) 1px, transparent 1px)',
backgroundSize: '26px 26px',
}}
/>
<div className="absolute -left-12 -top-10 h-32 w-32 rounded-full bg-white/40 blur-3xl" />
<div className="absolute -right-10 bottom-0 h-28 w-28 rounded-full bg-white/25 blur-3xl" />
<div className="relative z-10 m-3 rounded-2xl border border-white/35 bg-white/80 px-4 py-4 shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-white/60 blur-xl" />
<Avatar className="relative h-12 w-12 border border-white/50 bg-white/80 shadow-md">
<AvatarFallback className="text-xl">{emotionIcon}</AvatarFallback>
</Avatar>
</div>
<div className="space-y-1">
<Badge
className="border-0 text-[11px] font-semibold uppercase tracking-wide text-white shadow"
style={{ backgroundImage: gradientBackground }}
>
{card?.emotion?.name ?? 'Fotoaufgabe'}
</Badge>
<div className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-white/70">
<Sparkles className="h-4 w-4 text-amber-500" aria-hidden />
<span>Foto-Challenge</span>
</div>
</div>
</div>
<div className="hidden items-center gap-1 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-slate-950/70 dark:text-white/80 sm:flex">
<Timer className="mr-1 h-3.5 w-3.5 text-slate-500 dark:text-white/60" aria-hidden />
<span>ca. {durationMinutes} min</span>
</div>
</div>
<div className="mt-4 text-center">
{card ? (
isExpandable ? (
<button
type="button"
onClick={toggleExpanded}
className="mx-auto flex w-full flex-col items-center gap-1 text-center"
aria-expanded={isExpanded}
aria-controls={titleId}
>
<p
id={titleId}
ref={(node) => {
if (card) {
titleRefs.current.set(card.id, node);
}
}}
data-collapsed={!isExpanded}
className={titleClasses}
style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }}
>
{card.title}
</p>
<ChevronDown
className={`h-4 w-4 text-slate-600 transition-transform dark:text-white/70 ${isExpanded ? 'rotate-180' : ''}`}
aria-hidden
/>
<span className="sr-only">Titel ein- oder ausklappen</span>
</button>
) : (
<p
id={titleId}
ref={(node) => {
if (card) {
titleRefs.current.set(card.id, node);
}
}}
data-collapsed={!isExpanded}
className={titleClasses}
style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }}
>
{card.title}
</p>
)
) : loading ? (
<div className="space-y-2">
<Skeleton className="mx-auto h-6 w-3/4" />
<Skeleton className="mx-auto h-4 w-full" />
<Skeleton className="mx-auto h-4 w-5/6" />
</div>
) : (
<p className="text-sm text-slate-600 dark:text-white/70">Ziehe deine erste Mission oder wähle eine Stimmung.</p>
)}
</div>
{card?.description && normalizeText(card.title) !== normalizeText(card.description) ? (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1 text-left">
<p className="text-sm font-semibold text-slate-800 dark:text-white" style={titleFont}>
{card.title}
</p>
<p className="text-sm leading-relaxed text-slate-600 dark:text-white/70">{card.description}</p>
</div>
</div>
</div>
) : null}
<div className="mt-4 grid grid-cols-[2fr_1fr] gap-2">
<Button
asChild
className="w-full text-white shadow-lg"
style={{
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${primary}, ${secondary})`,
boxShadow: `0 12px 28px ${primary}25`,
}}
>
<Link
to={
card
? `/e/${encodeURIComponent(token)}/upload?task=${card.id}`
: `/e/${encodeURIComponent(token)}/tasks`
}
className="inline-flex items-center justify-center gap-2"
>
<Camera className="h-4 w-4" aria-hidden />
<span>Aufgabe starten</span>
</Link>
</Button>
<Button
type="button"
variant="secondary"
className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70 dark:text-white/80"
onClick={() => {
dismissSwipeHint();
onAdvance();
}}
disabled={loading}
style={{
borderRadius: `${radius}px`,
}}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
<span className="hidden sm:inline">Andere</span>
</Button>
</div>
</div>
</div>
);
};
const slides = cards.length ? cards : [mission ?? null];
const initialSlide = Math.min(initialIndex, Math.max(0, slides.length - 1));
return (
<Card className="border-0 bg-transparent py-2 shadow-none" style={{ fontFamily: bodyFont }}>
<CardContent className="px-0 py-0">
<div className="relative min-h-[240px] px-2 sm:min-h-[260px]" data-testid="mission-card-stack">
<AnimatePresence initial={false}>
{showSwipeHint ? (
<motion.div
className="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
>
<div className="flex items-center gap-2 rounded-full border border-white/40 bg-white/85 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70 dark:text-white/80">
<motion.span
animate={{ x: [-6, 0, -6] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
</motion.span>
<span>{swipeHintLabel}</span>
<motion.span
animate={{ x: [6, 0, 6] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
>
<ArrowRight className="h-3.5 w-3.5" aria-hidden />
</motion.span>
</div>
</motion.div>
) : null}
</AnimatePresence>
<Swiper
effect="cards"
modules={[EffectCards]}
cardsEffect={{
perSlideRotate: 1,
perSlideOffset: 1,
slideShadows: false,
}}
slidesPerView={1}
grabCursor
allowTouchMove
loop={slides.length > 1}
initialSlide={initialSlide}
onSwiper={(instance) => {
swiperRef.current = instance;
if (initialSlide > 0) {
instance.slideToLoop ? instance.slideToLoop(initialSlide, 0) : instance.slideTo(initialSlide, 0);
}
}}
onSlideChange={(instance) => {
const realIndex = typeof instance.realIndex === 'number' ? instance.realIndex : instance.activeIndex ?? 0;
if (realIndex !== lastSlideIndexRef.current) {
setExpandedTaskId(null);
dismissSwipeHint();
}
lastSlideIndexRef.current = realIndex;
onIndexChange(realIndex, slides.length);
}}
className="!pb-1"
style={{ paddingLeft: '0.25rem', paddingRight: '0.25rem' }}
>
{slides.map((card, index) => {
const key = `card-${card?.id ?? 'x'}-${index}`;
return (
<SwiperSlide key={key} className="!h-auto">
<div className="mx-auto w-full max-w-[calc(100vw-2rem)]">
{renderCardContent(card)}
</div>
</SwiperSlide>
);
})}
</Swiper>
</div>
</CardContent>
</Card>
);
}
function EmotionActionCard() {
return (
<Card className="border border-muted/40 shadow-sm">
<CardHeader className="py-[4px]">
<CardTitle>Wähle eine Stimmung und erhalte eine passende Aufgabe</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
Tippe deinen Mood, wir picken die nächste Mission für dich.
</CardDescription>
</CardHeader>
<CardContent className="py-[4px]">
<EmotionPicker variant="embedded" showSkip={false} />
</CardContent>
</Card>
);
}
export function UploadActionCard({
token,
accentColor,
secondaryAccent,
radius,
bodyFont,
requiresApproval,
}: {
token: string;
accentColor: string;
secondaryAccent: string;
radius: number;
bodyFont?: string;
requiresApproval: boolean;
}) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const navigate = useNavigate();
const { upload, uploading, error, warning, progress, reset } = useDirectUpload({
eventToken: token,
taskId: undefined,
emotionSlug: undefined,
onCompleted: () => {
setMessage(null);
navigate(`/e/${encodeURIComponent(token)}/gallery`);
},
});
const onPick = React.useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
setMessage(null);
try {
await upload(file);
} finally {
setBusy(false);
}
if (inputRef.current) {
inputRef.current.value = '';
}
},
[upload]
);
React.useEffect(() => {
if (error) {
setMessage(error);
} else if (warning) {
setMessage(warning);
} else {
setMessage(null);
}
}, [error, warning]);
return (
<Card
className="gap-2 overflow-hidden border border-muted/30 bg-[var(--guest-surface)] py-2 shadow-sm dark:border-slate-800/60 dark:bg-slate-950/70"
data-testid="upload-action-card"
style={{
borderRadius: `${radius}px`,
fontFamily: bodyFont,
}}
>
<CardContent className="flex flex-col gap-1.5 px-4 py-2">
<Button
type="button"
className="text-white justify-center"
style={{
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
boxShadow: `0 12px 28px ${accentColor}25`,
}}
disabled={busy || uploading}
onClick={() => {
reset();
setMessage(null);
inputRef.current?.click();
}}
>
<span className="flex items-center gap-2">
<UploadCloud className={`h-5 w-5 ${busy || uploading ? 'animate-pulse' : ''}`} aria-hidden />
<span>{busy || uploading ? 'Lädt …' : 'Direkt hochladen'}</span>
</span>
</Button>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={onPick}
/>
<p className="text-xs text-muted-foreground dark:text-white/70">
Kamera öffnen oder ein Foto aus deiner Galerie wählen. Offline möglich wir laden später hoch.
</p>
{requiresApproval ? (
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
Deine Fotos werden kurz geprüft und erscheinen danach in der Galerie.
</p>
) : null}
{message && (
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">
{message} {progress > 0 && progress < 100 ? `(${Math.round(progress)}%)` : ''}
</p>
)}
</CardContent>
</Card>
);
}