Modernize guest PWA header and homepage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-31 23:15:44 +01:00
parent e233cddcc8
commit 386d0004ed
6 changed files with 253 additions and 94 deletions

View File

@@ -660,6 +660,31 @@ html.guest-theme.dark {
50% { background-position: 100% 50%; }
}
.guest-aurora {
background-size: 300% 300%;
animation: aurora 18s ease infinite;
}
.guest-aurora-soft {
background-size: 260% 260%;
animation: aurora 26s ease-in-out infinite;
}
@keyframes guest-cta-pulse {
0%, 100% {
box-shadow: 0 12px 28px var(--cta-glow, rgba(0, 0, 0, 0.25)), 0 0 0 0 var(--cta-ring, rgba(0, 0, 0, 0));
transform: translateY(0);
}
50% {
box-shadow: 0 18px 45px var(--cta-glow, rgba(0, 0, 0, 0.35)), 0 0 0 12px var(--cta-ring, rgba(0, 0, 0, 0.12));
transform: translateY(-1px);
}
}
.guest-cta-pulse {
animation: guest-cta-pulse 3.2s ease-in-out infinite;
}
.bg-aurora-enhanced {
background: radial-gradient(circle at 20% 80%, #a8edea 0%, #fed6e3 50%, #d299c2 100%), linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%, 400% 400%;
@@ -672,6 +697,14 @@ html.guest-theme.dark {
animation: aurora 20s ease infinite;
}
@media (prefers-reduced-motion: reduce) {
.guest-aurora,
.guest-aurora-soft,
.guest-cta-pulse {
animation: none;
}
}
.guest-immersive .guest-header {
display: none !important;
}

View File

@@ -86,11 +86,12 @@ export default function BottomNav() {
return (
<div
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/70 via-black/45 to-black/10 px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/15 bg-gradient-to-t from-black/55 via-black/30 to-black/5 px-4 shadow-2xl backdrop-blur-xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/55 dark:to-gray-900/20 ${
compact ? 'pt-1' : 'pt-2 pb-1'
}`}
style={{ paddingBottom: navPaddingBottom }}
>
<div className="pointer-events-none absolute -top-7 inset-x-0 h-7 bg-gradient-to-b from-black/0 via-black/30 to-black/60 dark:via-black/40 dark:to-black/70" aria-hidden />
<div className="mx-auto flex max-w-lg items-center gap-3">
<div className="flex flex-1 justify-evenly gap-2">
<TabLink

View File

@@ -139,19 +139,24 @@ export default function EmotionPicker({
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"
className="group relative flex flex-col gap-2 rounded-2xl p-[1px] text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg active:scale-[0.98]"
style={{
backgroundImage: 'linear-gradient(135deg, color-mix(in oklch, var(--guest-primary) 45%, white), color-mix(in oklch, var(--guest-secondary) 40%, white))',
}}
>
<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 className="relative flex flex-col gap-2 rounded-[0.95rem] border border-white/50 bg-white/80 px-4 py-3 shadow-sm backdrop-blur-xl dark:border-white/10 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="text-sm font-medium 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>
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100 dark:text-white/60" />
</div>
</button>
);

View File

@@ -8,6 +8,7 @@ import { Heart } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
type Props = { token: string };
@@ -97,14 +98,16 @@ export default function GalleryPreview({ token }: Props) {
style={{ borderRadius: radius, fontFamily: bodyFont }}
>
<CardContent className="space-y-3 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Live-Galerie</p>
<div className="mb-1 inline-flex items-center rounded-full border border-white/50 bg-white/80 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70" style={headingFont ? { fontFamily: headingFont } : undefined}>
Live-Galerie
</div>
<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"
className="rounded-full border border-white/40 bg-white/70 px-3 py-1 text-sm font-semibold shadow-sm backdrop-blur transition hover:bg-white/90 dark:border-white/10 dark:bg-slate-950/70 dark:hover:bg-slate-950"
style={{ color: linkColor }}
>
Alle ansehen
@@ -112,28 +115,31 @@ export default function GalleryPreview({ token }: Props) {
</div>
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter, index) => {
<div className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter) => {
const isActive = mode === filter.value;
return (
<div key={filter.value} className="flex items-center">
<button
type="button"
onClick={() => setMode(filter.value)}
className={cn(
'inline-flex items-center rounded-full px-3 py-1.5 transition',
isActive
? 'bg-pink-500 text-white shadow'
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
)}
>
<span className="whitespace-nowrap">{filter.label}</span>
</button>
{index < filters.length - 1 && (
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
<button
key={filter.value}
type="button"
onClick={() => setMode(filter.value)}
className={cn(
'relative inline-flex items-center rounded-full px-3 py-1.5 transition',
isActive
? 'text-white'
: 'text-muted-foreground hover:text-pink-600 dark:text-white/70 dark:hover:text-white',
)}
</div>
>
{isActive && (
<motion.span
layoutId="gallery-filter-pill"
className="absolute inset-0 rounded-full bg-gradient-to-r from-pink-500 to-rose-500 shadow"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<span className="relative z-10 whitespace-nowrap">{filter.label}</span>
</button>
);
})}
</div>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import {
User,
@@ -22,13 +22,12 @@ import {
} from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
import { useOptionalEventStats } from '../context/EventStatsContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { SettingsSheet } from './settings-sheet';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
import { getContrastingTextColor, relativeLuminance, hexToRgb } from '../lib/color';
import { isTaskModeEnabled } from '../lib/engagement';
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -81,6 +80,14 @@ function getInitials(name: string): string {
return name.substring(0, 2).toUpperCase();
}
function toRgba(value: string, alpha: number): string {
const rgb = hexToRgb(value);
if (!rgb) {
return `rgba(255, 255, 255, ${alpha})`;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
function EventAvatar({
name,
icon,
@@ -167,9 +174,7 @@ function EventAvatar({
}
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
const location = useLocation();
const statsContext = useOptionalEventStats();
const identity = useOptionalGuestIdentity();
const { t } = useTranslation();
const brandingContext = useOptionalEventBranding();
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
@@ -206,23 +211,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
return () => document.removeEventListener('mousedown', handler);
}, [notificationsOpen]);
if (!eventToken) {
return (
<div
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>
</div>
<div className="flex items-center gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
);
}
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const logoPosition = branding.logo?.position ?? 'left';
@@ -231,16 +219,48 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
color: headerTextColor,
fontFamily: headerFont,
};
const headerGlowPrimary = toRgba(branding.primaryColor, 0.35);
const headerGlowSecondary = toRgba(branding.secondaryColor, 0.35);
const headerShimmer = `linear-gradient(120deg, ${toRgba(branding.primaryColor, 0.28)}, transparent 45%, ${toRgba(branding.secondaryColor, 0.32)})`;
const headerHairline = `linear-gradient(90deg, transparent, ${toRgba(headerTextColor, 0.4)}, transparent)`;
if (!eventToken) {
return (
<div
className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 bg-white/70 px-4 py-2 shadow-[0_14px_40px_-30px_rgba(15,23,42,0.6)] backdrop-blur-2xl dark:border-white/10 dark:bg-black/40"
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora-soft" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-8 right-0 h-24 w-24 rounded-full bg-white/60 blur-3xl dark:bg-white/10" aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-white/40 to-transparent dark:via-white/15" aria-hidden />
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
<div className="flex min-w-0 flex-col">
<div className="font-semibold">{title}</div>
</div>
<div className="ml-auto flex items-center justify-end gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
</div>
);
}
const accentColor = branding.secondaryColor;
if (status === 'loading') {
return (
<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 />
<SettingsSheet />
<div className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl" style={headerStyle}>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-10 right-[-32px] h-28 w-28 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
<div className="pointer-events-none absolute -bottom-8 left-1/3 h-20 w-40 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
<div className="ml-auto flex items-center justify-end gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
</div>
);
@@ -254,16 +274,20 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
return (
<div
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"
className="guest-header sticky top-0 z-30 relative flex flex-nowrap items-center gap-3 overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl"
style={headerStyle}
>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-12 right-[-40px] h-32 w-32 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
<div className="pointer-events-none absolute -bottom-10 left-1/3 h-24 w-44 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
<div
className={
logoPosition === 'center'
? 'flex flex-col items-center gap-2 text-center'
`relative z-10 flex min-w-0 flex-1 ${logoPosition === 'center'
? 'flex-col items-center gap-1 text-center'
: logoPosition === 'right'
? 'flex flex-row-reverse items-center gap-3'
: 'flex items-center gap-3'
? 'flex-row-reverse items-center gap-3'
: 'items-center gap-3'}`
}
>
<EventAvatar
@@ -277,7 +301,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
style={headerFont ? { fontFamily: headerFont } : undefined}
>
<div className="font-semibold text-lg">{event.name}</div>
<div className="truncate text-base font-semibold sm:text-lg">{event.name}</div>
<div className="flex items-center gap-2 text-xs opacity-70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{stats && tasksEnabled && (
<>
@@ -295,7 +319,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative z-10 ml-auto flex shrink-0 items-center justify-end gap-2">
{notificationCenter && eventToken && (
<NotificationButton
eventToken={eventToken}

View File

@@ -23,11 +23,20 @@ import 'swiper/css';
import 'swiper/css/effect-cards';
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
import { hexToRgb } from '../lib/color';
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';
function toRgba(value: string, alpha: number): string {
const rgb = hexToRgb(value);
if (!rgb) {
return `rgba(255, 255, 255, ${alpha})`;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
export default function HomePage() {
const { token } = useParams<{ token: string }>();
const { name, hydrated } = useGuestIdentity();
@@ -56,6 +65,14 @@ export default function HomePage() {
}
}, [heroStorageKey]);
React.useEffect(() => {
if (typeof document === 'undefined') {
return;
}
document.body.classList.remove('guest-immersive');
document.body.classList.remove('guest-nav-visible');
}, []);
const dismissHero = React.useCallback(() => {
setHeroVisible(false);
if (typeof window === 'undefined') {
@@ -73,6 +90,13 @@ export default function HomePage() {
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor;
const glowPrimary = toRgba(accentColor, 0.35);
const glowSecondary = toRgba(secondaryAccent, 0.32);
const shimmerGradient = `linear-gradient(120deg, ${toRgba(accentColor, 0.22)}, transparent 45%, ${toRgba(secondaryAccent, 0.32)})`;
const welcomePanelStyle = React.useMemo(() => ({
background: `linear-gradient(135deg, color-mix(in oklch, ${accentColor} 18%, var(--background)), color-mix(in oklch, ${secondaryAccent} 16%, var(--background)))`,
borderColor: toRgba(accentColor, 0.25),
}), [accentColor, secondaryAccent]);
const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
const tasksEnabled = isTaskModeEnabled(event);
@@ -80,6 +104,31 @@ export default function HomePage() {
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const backdropStyle = React.useMemo(() => ({
'--guest-glow-primary': glowPrimary,
'--guest-glow-secondary': glowSecondary,
}) as React.CSSProperties & Record<string, string>, [glowPrimary, glowSecondary]);
const renderWithBackdrop = (content: React.ReactNode) => (
<div className="relative -mx-4 px-4">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] overflow-hidden" style={backdropStyle} aria-hidden>
<div
className="absolute -top-24 left-1/2 h-[320px] w-[320px] -translate-x-1/2 rounded-full blur-3xl"
style={{ background: 'var(--guest-glow-primary)' }}
/>
<div
className="absolute -top-10 right-[-70px] h-[260px] w-[260px] rounded-full blur-3xl"
style={{ background: 'var(--guest-glow-secondary)' }}
/>
<div className="absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: shimmerGradient }} />
<div className="absolute inset-0 bg-[radial-gradient(120%_100%_at_50%_0%,rgba(255,255,255,0.7),transparent_70%)] opacity-70 dark:opacity-30" />
<div className="absolute inset-x-0 bottom-0 h-20 bg-gradient-to-b from-transparent to-[var(--background)]" />
</div>
<div className="relative z-10">
{content}
</div>
</div>
);
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
@@ -293,17 +342,24 @@ export default function HomePage() {
const introMessage = introMessageRef.current;
if (!tasksEnabled) {
return (
<motion.div className="space-y-3 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
return renderWithBackdrop(
<motion.div className="space-y-3 pb-28" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
<motion.section
className="space-y-1 px-4"
className="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>}
<div
className="rounded-2xl border bg-white/70 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
style={welcomePanelStyle}
>
<div className="space-y-1">
<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>}
</div>
</div>
</motion.section>
<motion.section className="space-y-2 px-4" {...fadeUpMotion}>
@@ -328,21 +384,28 @@ export default function HomePage() {
);
}
return (
<motion.div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
return renderWithBackdrop(
<motion.div className="space-y-0.5 pb-28" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
<motion.section
className="space-y-1 px-4"
className="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>
)}
<div
className="rounded-2xl border bg-white/70 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
style={welcomePanelStyle}
>
<div className="space-y-1">
<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>
)}
</div>
</div>
</motion.section>
{heroVisible && (
@@ -507,6 +570,7 @@ export function MissionActionCard({
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const cards = mission ? [mission, ...stack] : stack;
const shellRadius = `${radius + 10}px`;
const motionEnabled = !prefersReducedMotion();
const normalizeText = (value: string | undefined | null) =>
(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
const [expandedTaskId, setExpandedTaskId] = React.useState<number | null>(null);
@@ -516,8 +580,31 @@ export function MissionActionCard({
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 ctaPulseKey = token ? `guestMissionCtaPulse_${token}` : 'guestMissionCtaPulse';
const [ctaPulse, setCtaPulse] = React.useState(false);
React.useEffect(() => {
if (!motionEnabled || typeof window === 'undefined') {
return;
}
try {
if (window.sessionStorage.getItem(ctaPulseKey)) {
return;
}
} catch {
// ignore storage exceptions
}
setCtaPulse(true);
const timeout = window.setTimeout(() => setCtaPulse(false), 4800);
try {
window.sessionStorage.setItem(ctaPulseKey, '1');
} catch {
// ignore storage exceptions
}
return () => window.clearTimeout(timeout);
}, [ctaPulseKey, motionEnabled]);
const dismissSwipeHint = React.useCallback((persist = true) => {
if (!showSwipeHint) {
@@ -622,6 +709,13 @@ export function MissionActionCard({
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 ctaStyles = {
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${primary}, ${secondary})`,
boxShadow: `0 12px 28px ${primary}25`,
'--cta-glow': toRgba(primary, 0.35),
'--cta-ring': toRgba(primary, 0.16),
} as React.CSSProperties & Record<string, string>;
const toggleExpanded = () => {
if (!card) return;
setExpandedTaskId((prev) => (prev === card.id ? null : card.id));
@@ -748,12 +842,8 @@ export function MissionActionCard({
<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`,
}}
className={`w-full text-white shadow-lg ${ctaPulse ? 'guest-cta-pulse' : ''}`}
style={ctaStyles}
>
<Link
to={
@@ -769,8 +859,8 @@ export function MissionActionCard({
</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"
variant="ghost"
className="w-full border border-white/40 bg-white/60 text-slate-600 shadow-sm backdrop-blur transition hover:bg-white/80 hover:text-slate-800 dark:border-white/10 dark:bg-slate-950/60 dark:text-white/70 dark:hover:bg-slate-950/80 dark:hover:text-white"
onClick={() => {
dismissSwipeHint();
onAdvance();