793 lines
27 KiB
TypeScript
793 lines
27 KiB
TypeScript
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 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 { 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';
|
||
|
||
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 [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 (
|
||
<div className="space-y-3 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||
<section className="space-y-1 px-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||
<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>}
|
||
</section>
|
||
|
||
<section className="space-y-2 px-4">
|
||
<UploadActionCard
|
||
token={token}
|
||
accentColor={accentColor}
|
||
secondaryAccent={secondaryAccent}
|
||
radius={radius}
|
||
bodyFont={bodyFont}
|
||
requiresApproval={uploadsRequireApproval}
|
||
/>
|
||
</section>
|
||
|
||
<Separator />
|
||
|
||
<GalleryPreview token={token} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||
<section className="space-y-1 px-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||
<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>
|
||
)}
|
||
</section>
|
||
|
||
{heroVisible && (
|
||
<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`}
|
||
/>
|
||
)}
|
||
|
||
<section className="space-y-0.5" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||
<div className="space-y-0.5">
|
||
<MissionActionCard
|
||
token={token}
|
||
mission={missionDeck[0] ?? null}
|
||
loading={missionLoading}
|
||
onAdvance={advanceDeck}
|
||
stack={missionDeck.slice(1)}
|
||
initialIndex={poolIndexRef.current}
|
||
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>
|
||
</section>
|
||
|
||
<Separator />
|
||
|
||
<GalleryPreview token={token} />
|
||
</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;
|
||
};
|
||
|
||
function MissionActionCard({
|
||
token,
|
||
mission,
|
||
loading,
|
||
onAdvance,
|
||
stack,
|
||
initialIndex,
|
||
onIndexChange,
|
||
swiperRef,
|
||
}: {
|
||
token: string;
|
||
mission: MissionPreview | null;
|
||
loading: boolean;
|
||
onAdvance: () => void;
|
||
stack: MissionPreview[];
|
||
initialIndex: number;
|
||
onIndexChange: (index: number, total: number) => void;
|
||
swiperRef: React.MutableRefObject<any>;
|
||
}) {
|
||
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 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})`;
|
||
|
||
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">
|
||
<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">
|
||
<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 sm:flex">
|
||
<Timer className="mr-1 h-3.5 w-3.5 text-slate-500" aria-hidden />
|
||
<span>ca. {durationMinutes} min</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 text-center">
|
||
{card ? (
|
||
<p
|
||
className="text-xl font-semibold leading-snug text-slate-900 sm:text-2xl break-words py-1"
|
||
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">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" style={titleFont}>
|
||
{card.title}
|
||
</p>
|
||
<p className="text-sm leading-relaxed text-slate-600">{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`
|
||
}
|
||
>
|
||
Aufgabe starten
|
||
</Link>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur"
|
||
onClick={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 shadow-none" style={{ fontFamily: bodyFont }}>
|
||
<CardContent className="px-0 py-0">
|
||
<div className="relative min-h-[280px] px-2">
|
||
<Swiper
|
||
effect="cards"
|
||
modules={[EffectCards]}
|
||
cardsEffect={{
|
||
perSlideRotate: 2,
|
||
perSlideOffset: 4,
|
||
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;
|
||
onIndexChange(realIndex, slides.length);
|
||
}}
|
||
className="!pb-2"
|
||
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>
|
||
);
|
||
}
|
||
|
||
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="overflow-hidden border border-muted/30 shadow-sm"
|
||
style={{
|
||
background: 'var(--guest-surface)',
|
||
borderRadius: `${radius}px`,
|
||
fontFamily: bodyFont,
|
||
}}
|
||
>
|
||
<CardContent className="flex flex-col gap-2 py-[4px]">
|
||
<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">
|
||
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>
|
||
);
|
||
}
|