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

@@ -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();