enhancements of the homepage in the guest pwa

This commit is contained in:
Codex Agent
2025-12-15 19:05:27 +01:00
parent 763af12617
commit f2473c6f6d
14 changed files with 568 additions and 207 deletions

View File

@@ -11,7 +11,7 @@
@theme {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
'Montserrat', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-display: 'Playfair Display', serif;
--font-serif: 'Lora', serif;
--font-sans-marketing: 'Montserrat', sans-serif;
@@ -123,7 +123,7 @@
--guest-link: #007aff;
--guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-heading-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-heading-font: 'Playfair Display', 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-serif-font: 'Lora', serif;
}

View File

@@ -110,51 +110,54 @@ export default function EmotionPicker({
</div>
)}
<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 => {
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;
<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;
};
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 min-w-[180px] flex-col gap-2 rounded-2xl border border-white/40 bg-white/80 px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-pink-200 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 line-clamp-1">{localizedName}</div>
{localizedDescription && (
<div className="text-xs text-muted-foreground line-clamp-1">{localizedDescription}</div>
)}
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">{localizedName}</div>
{localizedDescription && (
<div className="text-xs text-muted-foreground line-clamp-2">{localizedDescription}</div>
)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100" />
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100" />
</div>
</button>
);
})}
</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 */}

View File

@@ -6,6 +6,7 @@ import { getDeviceId } from '../lib/device';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Heart } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
type Props = { token: string };
@@ -27,10 +28,15 @@ type PreviewPhoto = {
export default function GalleryPreview({ token }: Props) {
const { locale } = useTranslation();
const { branding } = useEventBranding();
const { photos, loading } = usePollGalleryDelta(token, locale);
const [mode, setMode] = React.useState<PreviewFilter>('latest');
const typedPhotos = React.useMemo(() => photos as PreviewPhoto[], [photos]);
const hasPhotobooth = React.useMemo(() => typedPhotos.some((p) => p.ingest_source === 'photobooth'), [typedPhotos]);
const radius = branding.buttons?.radius ?? 12;
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const items = React.useMemo(() => {
let arr = typedPhotos.slice();
@@ -84,64 +90,82 @@ export default function GalleryPreview({ token }: Props) {
];
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
<h3 className="text-lg font-semibold text-foreground">Alle Uploads auf einen Blick</h3>
<Card className="border border-muted/30 shadow-sm" style={{ borderRadius: radius, background: 'var(--guest-surface)', fontFamily: bodyFont }}>
<CardContent className="space-y-3 p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Live-Galerie</p>
<h3 className="text-lg font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Alle Uploads auf einen Blick</h3>
</div>
<Link
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
className="text-sm font-semibold transition"
style={{ color: linkColor }}
>
Alle ansehen
</Link>
</div>
<Link
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
className="text-sm font-semibold text-pink-600 hover:text-pink-700"
>
Alle ansehen
</Link>
</div>
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
{filters.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => setMode(filter.value)}
className={`rounded-full border px-4 py-1 transition ${
mode === filter.value
? 'border-pink-500 bg-pink-500 text-white shadow'
: 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200'
}`}
style={{
borderRadius: radius,
border: mode === filter.value ? `1px solid ${branding.primaryColor}` : `1px solid ${branding.primaryColor}22`,
background: mode === filter.value ? branding.primaryColor : 'var(--guest-surface)',
color: mode === filter.value ? '#ffffff' : 'var(--foreground)',
boxShadow: mode === filter.value ? `0 8px 18px ${branding.primaryColor}33` : 'none',
}}
className="px-4 py-1 transition"
>
{filter.label}
</button>
))}
</div>
{loading && <p className="text-sm text-muted-foreground">Lädt</p>}
{!loading && items.length === 0 && (
<Card>
<CardContent className="p-3 text-sm text-muted-foreground">
{loading && <p className="text-sm text-muted-foreground">Lädt</p>}
{!loading && items.length === 0 && (
<div className="flex items-center gap-3 rounded-xl border border-muted/30 bg-[var(--guest-surface)] p-3 text-sm text-muted-foreground">
<Heart className="h-4 w-4" style={{ color: branding.secondaryColor }} aria-hidden />
Noch keine Fotos. Starte mit deinem ersten Upload!
</CardContent>
</Card>
)}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2 sm:grid-cols-2">
{items.map((p: PreviewPhoto) => (
<Link
key={p.id}
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
className="group relative block overflow-hidden rounded-3xl border border-white/30 bg-gray-900 text-white shadow-lg"
className="group relative block overflow-hidden text-foreground"
style={{
borderRadius: radius,
border: `1px solid ${branding.primaryColor}22`,
background: 'var(--guest-surface)',
boxShadow: `0 12px 26px ${branding.primaryColor}22`,
}}
>
<img
src={p.thumbnail_path || p.file_path}
alt={p.title || 'Foto'}
className="h-48 w-full object-cover transition duration-300 group-hover:scale-105"
className="h-40 w-full object-cover transition duration-300 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" aria-hidden />
<div className="absolute bottom-0 left-0 right-0 p-4">
<p className="text-sm font-semibold leading-tight line-clamp-2">{p.title || getPhotoTitle(p)}</p>
<div className="mt-2 flex items-center gap-1 text-xs text-white/80">
<Heart className="h-4 w-4 fill-current" aria-hidden />
<div
className="absolute inset-0"
style={{
background: `linear-gradient(180deg, transparent 50%, ${branding.primaryColor}33 100%)`,
}}
aria-hidden
/>
<div className="absolute bottom-0 left-0 right-0 space-y-1 p-3">
<p className="text-sm font-semibold leading-tight line-clamp-2" style={headingFont ? { fontFamily: headingFont } : undefined}>
{p.title || getPhotoTitle(p)}
</p>
<div className="flex items-center gap-1 text-xs text-foreground/80">
<Heart className="h-4 w-4" style={{ color: branding.primaryColor }} aria-hidden />
{p.likes_count ?? 0}
</div>
</div>
@@ -149,12 +173,13 @@ export default function GalleryPreview({ token }: Props) {
))}
</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>
<p className="text-center text-sm text-muted-foreground">
Lust auf mehr?{' '}
<Link to={`/e/${encodeURIComponent(token)}/gallery`} className="font-semibold transition" style={{ color: linkColor }}>
Zur Galerie
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -172,19 +172,13 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
}, [notificationsOpen]);
if (!eventToken) {
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
return (
<div
className="guest-header sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40"
className="guest-header z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 dark:bg-black/40"
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
>
<div className="flex flex-col">
<div className="font-semibold">{title}</div>
{guestName && (
<span className="text-xs text-muted-foreground">
{`${t('common.hi')} ${guestName}`}
</span>
)}
</div>
<div className="flex items-center gap-2">
<AppearanceToggleDropdown />
@@ -194,20 +188,20 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
);
}
const guestName =
identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null;
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headerStyle: React.CSSProperties = {
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
color: headerTextColor,
fontFamily: branding.fontFamily ?? undefined,
fontFamily: headerFont,
};
const accentColor = branding.secondaryColor;
if (status === 'loading') {
return (
<div className="guest-header sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
<div className="guest-header z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
<div className="flex items-center gap-2">
<AppearanceToggleDropdown />
@@ -225,19 +219,14 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
return (
<div
className="guest-header 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"
className="guest-header z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
style={headerStyle}
>
<div className="flex items-center gap-3">
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)}
<div className="flex flex-col" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<div className="font-semibold text-base">{event.name}</div>
{guestName && (
<span className="text-xs text-white/80">
{`${t('common.hi')} ${guestName}`}
</span>
)}
<div className="flex items-center gap-2 text-xs text-white/70">
<div className="flex flex-col" style={headerFont ? { fontFamily: headerFont } : undefined}>
<div className="font-semibold text-lg">{event.name}</div>
<div className="flex items-center gap-2 text-xs text-white/70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{stats && (
<>
<span className="flex items-center gap-1">

View File

@@ -10,7 +10,7 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = {
primaryColor: '#f43f5e',
secondaryColor: '#fb7185',
backgroundColor: '#ffffff',
fontFamily: null,
fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
logoUrl: null,
palette: {
primary: '#f43f5e',
@@ -19,8 +19,8 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = {
surface: '#ffffff',
},
typography: {
heading: null,
body: null,
heading: 'Playfair Display, "Times New Roman", serif',
body: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
sizePreset: 'm',
},
logo: {
@@ -71,7 +71,7 @@ function resolveBranding(input?: EventBranding | null): EventBranding {
primaryColor: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
secondaryColor: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
backgroundColor: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
fontFamily: bodyFont?.trim() || null,
fontFamily: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.fontFamily,
logoUrl: logoMode === 'upload' ? (logoValue?.trim() || null) : null,
palette: {
primary: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
@@ -80,8 +80,8 @@ function resolveBranding(input?: EventBranding | null): EventBranding {
surface: normaliseHexColor(paletteSurface, paletteBackground ?? DEFAULT_EVENT_BRANDING.backgroundColor),
},
typography: {
heading: headingFont?.trim() || null,
body: bodyFont?.trim() || null,
heading: headingFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.heading || null,
body: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily,
sizePreset,
},
logo: {

View File

@@ -132,6 +132,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
home: {
fallbackGuestName: 'Gast',
welcomeLine: 'Willkommen {name}!',
introRotating: {
0: 'Hilf uns, diesen besonderen Tag mit deinen schönsten Momenten festzuhalten.',
1: 'Fang die Stimmung des Events ein und teile sie mit allen Gästen.',
2: 'Deine Sicht zählt: Halte Augenblicke fest, die sonst niemand bemerkt.',
3: 'Erfülle kleine Fotoaufgaben und fülle die gemeinsame Galerie mit Leben.',
4: 'Zeig uns, was du siehst deine Fotos erzählen die Geschichte dieses Tages.',
5: 'Mach aus Schnappschüssen gemeinsame Erinnerungen in einer großen Event-Galerie.',
6: 'Diese App ist eure Fotozentrale fotografiere, lade hoch und begeistere die anderen.',
7: 'Sorge dafür, dass kein wichtiger Moment verloren geht mit deinen Bildern.',
8: 'Lass dich von Fotoaufgaben inspirieren und halte die besonderen Szenen fest.',
9: 'Mach mit beim Fotospiel: Deine Fotos machen dieses Event unvergesslich.',
},
hero: {
subtitle: 'Willkommen zur Party',
title: 'Hey {name}!',
@@ -776,6 +789,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
home: {
fallbackGuestName: 'Guest',
welcomeLine: 'Welcome {name}!',
introRotating: {
0: 'Help us capture this special day with your favourite moments.',
1: 'Capture the mood of the event and share it with everyone.',
2: 'Your view matters: save the moments that others might miss.',
3: 'Complete playful photo missions and fill the shared gallery with life.',
4: 'Show us what you see your photos tell the story of this day.',
5: 'Turn quick snapshots into shared memories in one big event gallery.',
6: 'This app is your photo hub shoot, upload, and delight the other guests.',
7: 'Make sure no important moment gets lost with your pictures.',
8: 'Let photo missions inspire you and capture the scenes that really matter.',
9: 'Join the photo game: your pictures make this event unforgettable.',
},
hero: {
subtitle: 'Welcome to the party',
title: 'Hey {name}!',

View File

@@ -322,7 +322,7 @@ export default function GalleryPage() {
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
/>
{loading && <p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>{t('galleryPage.loading', 'Lade…')}</p>}
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-3 gap-2 px-4 pb-16 sm:grid-cols-3 md:grid-cols-4">
{list.map((p: GalleryPhoto) => {
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
const createdLabel = p.created_at
@@ -357,7 +357,7 @@ export default function GalleryPage() {
<img
src={imageUrl}
alt={altText}
className="h-64 w-full object-cover transition duration-500 group-hover:scale-105"
className="aspect-[3/4] w-full object-cover transition duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}}
@@ -425,6 +425,20 @@ export default function GalleryPage() {
</div>
);
})}
{list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
<div
key={`placeholder-${idx}`}
className="relative overflow-hidden border border-muted/40 bg-[var(--guest-surface,#f7f7f7)] shadow-sm"
style={{ borderRadius: radius }}
>
<div className="absolute inset-0 bg-gradient-to-br from-white/60 via-white/30 to-transparent dark:from-white/5 dark:via-white/0" aria-hidden />
<div className="flex aspect-[3/4] items-center justify-center gap-2 p-4 text-muted-foreground/70">
<ImageIcon className="h-6 w-6" aria-hidden />
<div className="h-2 w-10 rounded-full bg-muted/40" />
</div>
<div className="absolute inset-0 animate-pulse bg-white/30 dark:bg-white/5" aria-hidden />
</div>
))}
</div>
{currentPhotoIndex !== null && list.length > 0 && (
<PhotoLightbox

View File

@@ -12,6 +12,8 @@ import { Sparkles, UploadCloud, X, RefreshCw } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding';
import { animated, useSpring } from '@react-spring/web';
import { useGesture } from '@use-gesture/react';
export default function HomePage() {
const { token } = useParams<{ token: string }>();
@@ -25,17 +27,7 @@ export default function HomePage() {
const radius = branding.buttons?.radius ?? 12;
const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed';
const [heroVisible, setHeroVisible] = React.useState(() => {
if (typeof window === 'undefined') {
return true;
}
try {
return window.sessionStorage.getItem(heroStorageKey) !== '1';
} catch {
return true;
}
});
const [heroVisible, setHeroVisible] = React.useState(false);
React.useEffect(() => {
if (typeof window === 'undefined') {
@@ -43,9 +35,11 @@ export default function HomePage() {
}
try {
setHeroVisible(window.sessionStorage.getItem(heroStorageKey) !== '1');
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(true);
setHeroVisible(false);
}
}, [heroStorageKey]);
@@ -67,20 +61,37 @@ export default function HomePage() {
const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor;
const [missionPreview, setMissionPreview] = React.useState<MissionPreview | null>(null);
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
const [missionLoading, setMissionLoading] = React.useState(false);
const missionPoolRef = React.useRef<MissionPreview[]>([]);
const shuffleMissionPreview = React.useCallback(() => {
const drawRandom = React.useCallback((excludeIds: Set<number>) => {
const pool = missionPoolRef.current.filter((item) => !excludeIds.has(item.id));
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}, []);
const resetDeck = React.useCallback(() => {
const pool = missionPoolRef.current;
if (!pool.length) {
setMissionPreview(null);
setMissionDeck([]);
return;
}
const choice = pool[Math.floor(Math.random() * pool.length)];
setMissionPreview(choice);
const shuffled = [...pool].sort(() => Math.random() - 0.5);
setMissionDeck(shuffled.slice(0, 4));
}, []);
const advanceDeck = React.useCallback(() => {
setMissionDeck((prev) => {
if (!prev.length) return prev;
const [, ...rest] = prev;
const exclude = new Set(rest.map((r) => r.id));
const nextCandidate = drawRandom(exclude);
const replenished = nextCandidate ? [...rest, nextCandidate] : rest;
return replenished;
});
}, [drawRandom]);
React.useEffect(() => {
if (!token) return;
let cancelled = false;
@@ -108,16 +119,16 @@ export default function HomePage() {
duration: typeof task.duration === 'number' ? task.duration : 3,
emotion: task.emotion ?? null,
}));
shuffleMissionPreview();
resetDeck();
} else {
missionPoolRef.current = [];
setMissionPreview(null);
setMissionDeck([]);
}
} catch (err) {
if (!cancelled) {
console.warn('Mission preview failed', err);
missionPoolRef.current = [];
setMissionPreview(null);
setMissionDeck([]);
}
} finally {
if (!cancelled) {
@@ -129,14 +140,35 @@ export default function HomePage() {
return () => {
cancelled = true;
};
}, [shuffleMissionPreview, token, locale]);
}, [resetDeck, token, locale]);
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 introMessage =
introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : '';
return (
<div className="space-y-6 pb-32" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<section className="space-y-1 px-4">
<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}
@@ -150,23 +182,17 @@ export default function HomePage() {
/>
)}
<section className="space-y-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
<div className="flex items-center justify-between gap-3">
<div>
<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>
</div>
<div className="space-y-3">
<section className="space-y-0.5" style={headingFont ? { fontFamily: headingFont } : undefined}>
<div className="space-y-0.5">
<MissionActionCard
token={token}
mission={missionPreview}
mission={missionDeck[0] ?? null}
loading={missionLoading}
onShuffle={shuffleMissionPreview}
onAdvance={advanceDeck}
stack={missionDeck.slice(0, 3)}
/>
<EmotionActionCard />
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} radius={radius} bodyFont={bodyFont} />
<EmotionActionCard />
</div>
</section>
@@ -254,58 +280,233 @@ function MissionActionCard({
token,
mission,
loading,
onShuffle,
onAdvance,
stack,
}: {
token: string;
mission: MissionPreview | null;
loading: boolean;
onShuffle: () => void;
onAdvance: () => void;
stack: MissionPreview[];
}) {
const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12;
const primary = branding.buttons?.primary ?? branding.primaryColor;
const secondary = branding.buttons?.secondary ?? branding.secondaryColor;
const buttonStyle = branding.buttons?.style ?? 'filled';
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const textColor = '#1f2937';
const subTextColor = '#334155';
const swipeThreshold = 120;
const stackLayers = stack.slice(1, 4);
const cardStyle: React.CSSProperties = {
borderRadius: `${radius + 8}px`,
backgroundColor: '#fcf7ef',
backgroundImage: `linear-gradient(0deg, ${primary}33, ${primary}22), url(/patterns/rays-sunburst.svg)`,
backgroundBlendMode: 'multiply, normal',
backgroundSize: '330% 330%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
filter: 'contrast(1.12) saturate(1.1)',
border: `1px solid ${primary}26`,
boxShadow: `0 12px 28px ${primary}22, 0 2px 6px ${primary}1f`,
fontFamily: bodyFont,
position: 'relative',
overflow: 'hidden',
};
const [{ x, y, rotateZ, rotateY, rotateX, scale, opacity }, api] = useSpring(() => ({
x: 0,
y: 0,
rotateZ: 0,
rotateY: 0,
rotateX: 0,
scale: 1,
opacity: 1,
config: { tension: 320, friction: 26 },
}));
React.useEffect(() => {
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
}, [mission?.id, api]);
const bind = useGesture(
{
onDrag: ({ active, movement: [mx, my], velocity: [vx], direction: [dx], cancel }) => {
if (active && Math.abs(mx) > swipeThreshold) {
cancel?.();
api.start({
x: dx > 0 ? 520 : -520,
y: my,
rotateZ: dx > 0 ? 12 : -12,
rotateY: dx > 0 ? 18 : -18,
rotateX: -my / 10,
opacity: 0,
scale: 1,
immediate: false,
onRest: () => {
onAdvance();
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, opacity: 1, scale: 1, immediate: false });
},
});
return;
}
api.start({
x: mx,
y: my,
rotateZ: mx / 18,
rotateY: mx / 28,
rotateX: -my / 36,
scale: active ? 1.02 : 1,
opacity: 1,
immediate: false,
});
},
onDragEnd: () => {
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
},
},
{
drag: {
filterTaps: true,
bounds: { left: -200, right: 200, top: -120, bottom: 120 },
rubberband: true,
},
}
);
return (
<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}
<Card className="border-0 shadow-none bg-transparent" style={{ fontFamily: bodyFont }}>
<CardContent className="px-0 py-0">
<div className="relative min-h-[280px]">
{stackLayers.map((item, index) => {
const depth = index + 1;
const scaleDown = 1 - depth * 0.03;
const translateY = depth * 12;
const fade = Math.max(0.25, 0.55 - depth * 0.08);
return (
<div
key={item.id ?? index}
className="absolute inset-0 pointer-events-none"
style={{
...cardStyle,
transform: `translateY(${translateY}px) scale(${scaleDown})`,
opacity: fade,
filter: 'brightness(0.96) contrast(0.98)',
}}
aria-hidden
/>
);
})}
<animated.div
className="relative overflow-hidden touch-pan-y"
style={{
...cardStyle,
x,
y,
rotateZ,
rotateY,
rotateX,
scale,
opacity,
transformOrigin: 'center center',
willChange: 'transform',
}}
{...bind()}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
Andere Mission
</Button>
</div>
<div
className="absolute inset-x-0 top-0 h-12"
style={{
background: `linear-gradient(90deg, ${secondary}80, ${primary}cc)`,
filter: 'saturate(1.05)',
}}
aria-hidden
/>
<div className="relative z-10 flex flex-col gap-3 px-5 pb-4 pt-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/90 text-foreground shadow-sm"
style={{ borderRadius: `${radius - 2}px` }}
>
<Sparkles className="h-5 w-5" aria-hidden />
</div>
<div>
<p
className="text-xs font-semibold uppercase tracking-wide"
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: subTextColor }}
>
Fotoaufgabe
</p>
<p className="text-sm" style={{ color: subTextColor }}>
Wir haben schon etwas für dich vorbereitet.
</p>
</div>
</div>
</div>
{mission ? (
<div className="space-y-2">
<div
className="rounded-[14px] py-3 text-center"
style={{
background: 'rgba(255,255,255,0.6)',
paddingLeft: '30px',
paddingRight: '30px',
}}
>
<p
className="text-lg font-semibold leading-snug"
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: textColor }}
>
{mission.title}
</p>
{mission.description && (
<p className="text-sm leading-relaxed" style={{ color: subTextColor }}>
{mission.description}
</p>
)}
</div>
</div>
) : (
<p className="text-sm text-center" style={{ color: subTextColor }}>
Ziehe deine erste Mission im Aufgaben-Tab oder wähle eine Stimmung.
</p>
)}
<div className="grid grid-cols-[2fr_1fr] gap-2 pt-1">
<Button asChild className="w-full" style={{ borderRadius: `${radius}px` }}>
<Link
to={
mission
? `/e/${encodeURIComponent(token)}/upload?task=${mission.id}`
: `/e/${encodeURIComponent(token)}/tasks`
}
>
Aufgabe starten
</Link>
</Button>
<Button
type="button"
variant="secondary"
className="w-full"
onClick={onShuffle}
disabled={loading}
style={{
borderRadius: `${radius}px`,
backgroundColor: secondary,
color: '#ffffff',
}}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
Andere Aufgabe
</Button>
</div>
</div>
</animated.div>
</CardContent>
</Card>
);
@@ -314,13 +515,13 @@ function MissionActionCard({
function EmotionActionCard() {
return (
<Card className="border border-muted/40 shadow-sm">
<CardHeader>
<CardTitle>Foto nach Gefühlslage</CardTitle>
<CardHeader className="py-[4px]">
<CardTitle>Wähle eine Stimmung und erhalte eine passende Aufgabe</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
Wähle deine Stimmung, wir schlagen dir passende Missionen vor.
Tippe deinen Mood, wir picken die nächste Mission für dich.
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="py-[4px]">
<EmotionPicker variant="embedded" showSkip={false} />
</CardContent>
</Card>
@@ -342,30 +543,35 @@ function UploadActionCard({
}) {
return (
<Card
className="overflow-hidden border-0 text-white shadow-sm"
className="overflow-hidden border border-muted/30 shadow-sm"
style={{
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
background: 'var(--guest-surface)',
borderRadius: `${radius}px`,
fontFamily: bodyFont,
}}
>
<CardContent className="flex flex-col gap-3 py-5">
<CardContent className="flex flex-col gap-1.5 py-[4px]">
<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 className="rounded-2xl p-3" style={{ background: `${accentColor}15` }}>
<UploadCloud className="h-5 w-5" aria-hidden style={{ color: accentColor }} />
</div>
<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>
<p className="text-lg font-semibold text-foreground">Direkt hochladen</p>
<p className="text-sm text-muted-foreground">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
</div>
</div>
<Button
asChild
className="bg-white/90 text-slate-900 hover:bg-white"
style={{ borderRadius: `${radius}px` }}
className="text-white"
style={{
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
boxShadow: `0 12px 28px ${accentColor}25`,
}}
>
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
</Button>
<p className="text-xs text-muted-foreground">Offline möglich wir laden später hoch.</p>
</CardContent>
</Card>
);