Modernize guest PWA header and homepage
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user