Modernize guest PWA header and homepage
This commit is contained in:
@@ -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