diff --git a/app/catalyst-nyc/page.tsx b/app/catalyst-nyc/page.tsx new file mode 100644 index 000000000..c4430d1a5 --- /dev/null +++ b/app/catalyst-nyc/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; +import { CatalystFlow } from '@/components/events/catalyst/CatalystFlow'; +import { CATALYST_NYC_EVENT } from '@/components/events/catalyst/constants'; + +export const metadata: Metadata = { + title: CATALYST_NYC_EVENT.metadata.title, + description: CATALYST_NYC_EVENT.metadata.description, + robots: { index: false, follow: false, nocache: true }, +}; + +export default function CatalystNycPage() { + return ; +} diff --git a/components/Auth/AuthContent.tsx b/components/Auth/AuthContent.tsx index c60536bd2..e2fcb3a0b 100644 --- a/components/Auth/AuthContent.tsx +++ b/components/Auth/AuthContent.tsx @@ -1,12 +1,14 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, type ReactNode } from 'react'; import SelectProvider from './screens/SelectProvider'; import Login from './screens/Login'; import Signup from './screens/Signup'; import VerifyEmail from './screens/VerifyEmail'; import ForgotPassword from './screens/ForgotPassword'; -import { AuthScreen } from './types'; +import { AuthScreen, type AuthAppearance, type CatalystSurface } from './types'; import AnalyticsService, { LogEvent } from '@/services/analytics.service'; +export type { AuthAppearance, CatalystSurface } from './types'; + interface AuthContentProps { onClose?: () => void; onSuccess?: () => void; @@ -16,6 +18,12 @@ interface AuthContentProps { modalView?: boolean; /** URL to redirect to after Google OAuth login */ callbackUrl?: string; + appearance?: AuthAppearance; + catalystSurface?: CatalystSurface; + onScreenChange?: (screen: AuthScreen) => void; + entryTitle?: ReactNode; + entryNote?: ReactNode; + emailLabel?: string; } export default function AuthContent({ @@ -26,12 +34,22 @@ export default function AuthContent({ showHeader = true, modalView = false, callbackUrl, + appearance = 'default', + catalystSurface = 'dark', + onScreenChange, + entryTitle, + entryNote, + emailLabel, }: AuthContentProps) { const [screen, setScreen] = useState(initialScreen); const [email, setEmail] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(initialError || null); + useEffect(() => { + onScreenChange?.(screen); + }, [screen, onScreenChange]); + useEffect(() => { if (screen === 'LOGIN') { AnalyticsService.logEvent(LogEvent.LOGIN_VIA_EMAIL_INITIATED); @@ -62,6 +80,11 @@ export default function AuthContent({ onContinue={() => setScreen('LOGIN')} onSignup={() => setScreen('SIGNUP')} isLoading={isLoading} + appearance={appearance} + catalystSurface={catalystSurface} + entryTitle={entryTitle} + entryNote={entryNote} + emailLabel={emailLabel} /> )} {screen === 'LOGIN' && ( diff --git a/components/Auth/screens/SelectProvider.tsx b/components/Auth/screens/SelectProvider.tsx index ad2a8c1ec..91493d88d 100644 --- a/components/Auth/screens/SelectProvider.tsx +++ b/components/Auth/screens/SelectProvider.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { signIn } from 'next-auth/react'; import { AuthService } from '@/services/auth.service'; import { ApiError } from '@/services/types/api'; @@ -6,6 +7,8 @@ import { useAutoFocus } from '@/hooks/useAutoFocus'; import AnalyticsService, { LogEvent } from '@/services/analytics.service'; import { useReferral } from '@/contexts/ReferralContext'; import { Button } from '@/components/ui/Button'; +import type { AuthAppearance, CatalystSurface } from '../types'; +import { CATALYST_NYC_EVENT } from '@/components/events/catalyst/constants'; interface SelectProviderProps { onContinue: () => void; @@ -19,6 +22,34 @@ interface SelectProviderProps { setIsLoading?: (isLoading: boolean) => void; /** URL to redirect to after Google OAuth login */ callbackUrl?: string; + appearance?: AuthAppearance; + catalystSurface?: CatalystSurface; + entryTitle?: ReactNode; + entryNote?: ReactNode; + emailLabel?: string; +} + +function GoogleGlyph() { + return ( + + ); } export default function SelectProvider({ @@ -32,9 +63,18 @@ export default function SelectProvider({ showHeader = true, setIsLoading, callbackUrl, + appearance = 'default', + catalystSurface = 'dark', + entryTitle, + entryNote, + emailLabel, }: SelectProviderProps) { const emailInputRef = useAutoFocus(true); const { referralCode } = useReferral(); + const { auth } = CATALYST_NYC_EVENT; + const isCatalystDark = appearance === 'catalyst' && catalystSurface === 'dark'; + const isCatalystLight = appearance === 'catalyst' && catalystSurface === 'light'; + const resolvedEmailLabel = emailLabel ?? auth.emailLabel; const handleCheckAccount = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -51,7 +91,7 @@ export default function SelectProvider({ if (response.exists) { if (response.auth === 'google') { - signIn('google', { callbackUrl: '/' }); + handleGoogleSignIn(); } else if (response.is_verified) { onContinue(); } else { @@ -68,18 +108,16 @@ export default function SelectProvider({ }; const handleGoogleSignIn = async () => { - AnalyticsService.logEvent(LogEvent.AUTH_VIA_GOOGLE_INITIATED).catch((error) => { - console.error('Analytics failed:', error); + AnalyticsService.logEvent(LogEvent.AUTH_VIA_GOOGLE_INITIATED).catch((analyticsError) => { + console.error('Analytics failed:', analyticsError); }); - // Use prop if provided, otherwise check URL params, fallback to home const searchParams = new URLSearchParams(window.location.search); const originalCallbackUrl = callbackUrl || searchParams.get('callbackUrl') || '/'; let finalCallbackUrl = originalCallbackUrl; if (referralCode) { - // Create referral application URL with referral code and redirect as URL parameters const referralUrl = new URL('/referral/join/apply-referral-code', window.location.origin); referralUrl.searchParams.set('refr', referralCode); referralUrl.searchParams.set('redirect', originalCallbackUrl); @@ -89,9 +127,163 @@ export default function SelectProvider({ signIn('google', { callbackUrl: finalCallbackUrl }); }; + if (isCatalystDark) { + return ( +
+ {entryTitle} + + {error &&

{error}

} + +
+ + setEmail(e.target.value)} + /> + + +
+ + OR + +
+ + +
+ + {entryNote} + + +
+ ); + } + return (
- {showHeader && ( + {showHeader && appearance === 'default' && ( <>

Welcome to ResearchHub{' '} @@ -106,14 +298,25 @@ export default function SelectProvider({ )} + {isCatalystLight && entryTitle} + {error &&
{error}
}
+ {isCatalystLight && ( + + )} setEmail(e.target.value)} - placeholder="Email" + placeholder={isCatalystLight ? auth.emailPlaceholder : 'Email'} autoCapitalize="none" autoComplete="email" className="w-full p-3 border rounded mb-4" @@ -121,7 +324,7 @@ export default function SelectProvider({ />
@@ -156,9 +359,11 @@ export default function SelectProvider({ fill="#EA4335" /> - Continue with Google + {auth.googleLabel} + + {isCatalystLight && entryNote}
); } diff --git a/components/Auth/types.ts b/components/Auth/types.ts index 32b0d5dae..ad8a37659 100644 --- a/components/Auth/types.ts +++ b/components/Auth/types.ts @@ -5,6 +5,9 @@ export type AuthScreen = | 'VERIFY_EMAIL' | 'FORGOT_PASSWORD'; +export type AuthAppearance = 'default' | 'catalyst'; +export type CatalystSurface = 'dark' | 'light'; + export interface BaseScreenProps { onBack?: () => void; onClose: () => void; diff --git a/components/events/catalyst/CatalystArrivalBody.tsx b/components/events/catalyst/CatalystArrivalBody.tsx new file mode 100644 index 000000000..d83cb382c --- /dev/null +++ b/components/events/catalyst/CatalystArrivalBody.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { CATALYST_NYC_EVENT, formatMoney } from './constants'; + +const { + creditAmount, + yieldRate, + yieldLabel, + barCount, + maxYears, + animationIntervalMs, + arrival, + footer, +} = CATALYST_NYC_EVENT; + +const MAX = creditAmount * (1 + yieldRate * maxYears); + +const BARS = Array.from({ length: barCount }, (_, i) => ({ + year: i, + heightPct: Math.max(3, ((creditAmount * (1 + yieldRate * i)) / MAX) * 100), +})); + +interface CatalystArrivalBodyProps { + onClaim: () => void; + compact?: boolean; +} + +export function CatalystArrivalBody({ + onClaim, + compact = false, +}: Readonly) { + const [yr, setYr] = useState(0); + const holdRef = useRef(0); + + useEffect(() => { + const prefersReducedMotion = globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReducedMotion) { + return undefined; + } + + const id = setInterval(() => { + setYr((prev) => { + if (prev === 0 && holdRef.current < 3) { + holdRef.current += 1; + return prev; + } + holdRef.current = 0; + return prev >= maxYears ? 0 : prev + 1; + }); + }, animationIntervalMs); + + return () => clearInterval(id); + }, []); + + const value = creditAmount * (1 + yieldRate * yr); + const yearWord = yr === 1 ? 'year' : 'years'; + const phaseLabel = yr === 0 ? 'Your starting balance today' : `After ${yr} ${yearWord}`; + const yrText = `${phaseLabel} ยท ${yieldLabel}`; + + return ( + <> +
+
{formatMoney(value)}
+
{yrText}
+
+ {BARS.map(({ year, heightPct }) => { + const active = year <= yr; + return ( +
+
+ Now + 5 years + 10 years +
+
+ +

{arrival.headline}

+ +
+ +
{footer}
+
+ + + + ); +} diff --git a/components/events/catalyst/CatalystArrivalScreen.tsx b/components/events/catalyst/CatalystArrivalScreen.tsx new file mode 100644 index 000000000..bf4366ef0 --- /dev/null +++ b/components/events/catalyst/CatalystArrivalScreen.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { CatalystArrivalBody } from './CatalystArrivalBody'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystScreenShell } from './CatalystScreenShell'; + +interface CatalystArrivalScreenProps { + onClaim: () => void; +} + +export function CatalystArrivalScreen({ onClaim }: Readonly) { + return ( + + + + + ); +} diff --git a/components/events/catalyst/CatalystAuthEntryChrome.tsx b/components/events/catalyst/CatalystAuthEntryChrome.tsx new file mode 100644 index 000000000..f7d5de840 --- /dev/null +++ b/components/events/catalyst/CatalystAuthEntryChrome.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { CATALYST_NYC_EVENT } from './constants'; + +const { auth } = CATALYST_NYC_EVENT; + +interface CatalystAuthEntryTitleProps { + surface: 'dark' | 'light'; +} + +export function CatalystAuthEntryTitle({ surface }: Readonly) { + const onDark = surface === 'dark'; + + return ( + <> +

+ {auth.titleLines[0]} +
+ {auth.titleLines[1]} +

+ + + + ); +} + +interface CatalystAuthEntryNoteProps { + surface: 'dark' | 'light'; +} + +function InfoGlyph({ stroke }: Readonly<{ stroke: string }>) { + return ( + + ); +} + +export function CatalystAuthEntryNote({ surface }: Readonly) { + const onDark = surface === 'dark'; + + return ( + <> +

+ + + Use the {auth.noteHighlight}. + +

+ + + + ); +} diff --git a/components/events/catalyst/CatalystAuthModal.tsx b/components/events/catalyst/CatalystAuthModal.tsx new file mode 100644 index 000000000..fa04ead5e --- /dev/null +++ b/components/events/catalyst/CatalystAuthModal.tsx @@ -0,0 +1,70 @@ +'use client'; + +import AuthContent from '@/components/Auth/AuthContent'; +import { Button } from '@/components/ui/Button'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faXmark } from '@fortawesome/pro-light-svg-icons'; +import { CatalystAuthEntryNote, CatalystAuthEntryTitle } from './CatalystAuthEntryChrome'; +import { CATALYST_NYC_EVENT } from './constants'; +import { CatalystLockup } from './CatalystLockup'; + +const { auth, route } = CATALYST_NYC_EVENT; + +interface CatalystAuthModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +export function CatalystAuthModal({ + isOpen, + onClose, + onSuccess, +}: Readonly) { + if (!isOpen) { + return null; + } + + return ( +
+ + +
+ +
+ + } + entryNote={} + /> + +
+ ); +} diff --git a/components/events/catalyst/CatalystAuthScreen.tsx b/components/events/catalyst/CatalystAuthScreen.tsx new file mode 100644 index 000000000..dd2379d24 --- /dev/null +++ b/components/events/catalyst/CatalystAuthScreen.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useState } from 'react'; +import AuthContent from '@/components/Auth/AuthContent'; +import type { AuthScreen } from '@/components/Auth/types'; +import { CatalystAuthEntryNote, CatalystAuthEntryTitle } from './CatalystAuthEntryChrome'; +import { CATALYST_NYC_EVENT } from './constants'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystScreenShell } from './CatalystScreenShell'; + +const { footer, auth, route } = CATALYST_NYC_EVENT; + +interface CatalystAuthScreenProps { + initialScreen?: AuthScreen; +} + +export function CatalystAuthScreen({ + initialScreen = 'SELECT_PROVIDER', +}: Readonly) { + const [screen, setScreen] = useState(initialScreen); + + return ( + + + +
+
+ } + entryNote={} + /> +
+
+ +
{footer}
+ + +
+ ); +} diff --git a/components/events/catalyst/CatalystDesktopLoggedIn.tsx b/components/events/catalyst/CatalystDesktopLoggedIn.tsx new file mode 100644 index 000000000..f14d06ba2 --- /dev/null +++ b/components/events/catalyst/CatalystDesktopLoggedIn.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { PageLayout } from '@/app/layouts/PageLayout'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystLoggedInBody } from './CatalystLoggedInBody'; + +interface CatalystDesktopLoggedInProps { + email: string; +} + +export function CatalystDesktopLoggedIn({ email }: Readonly) { + return ( + +
+
+ + +
+
+ + +
+ ); +} diff --git a/components/events/catalyst/CatalystDesktopOffer.tsx b/components/events/catalyst/CatalystDesktopOffer.tsx new file mode 100644 index 000000000..2479a3706 --- /dev/null +++ b/components/events/catalyst/CatalystDesktopOffer.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { PageLayout } from '@/app/layouts/PageLayout'; +import { CatalystArrivalBody } from './CatalystArrivalBody'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystVioletBackdrop } from './CatalystVioletBackdrop'; + +interface CatalystDesktopOfferProps { + onClaim: () => void; +} + +export function CatalystDesktopOffer({ onClaim }: Readonly) { + return ( + +
+
+ +
+ + +
+
+
+ + +
+ ); +} diff --git a/components/events/catalyst/CatalystFlow.tsx b/components/events/catalyst/CatalystFlow.tsx new file mode 100644 index 000000000..5178fb73e --- /dev/null +++ b/components/events/catalyst/CatalystFlow.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState } from 'react'; +import { PageLayout } from '@/app/layouts/PageLayout'; +import { useUser } from '@/contexts/UserContext'; +import { CatalystArrivalScreen } from './CatalystArrivalScreen'; +import { CatalystAuthModal } from './CatalystAuthModal'; +import { CatalystAuthScreen } from './CatalystAuthScreen'; +import { CatalystDesktopLoggedIn } from './CatalystDesktopLoggedIn'; +import { CatalystDesktopOffer } from './CatalystDesktopOffer'; +import { CatalystLoggedInScreen } from './CatalystLoggedInScreen'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystScreenShell } from './CatalystScreenShell'; +import { useCatalystLayout } from './useCatalystLayout'; + +type Step = 'arrival' | 'auth'; + +function MobileLoadingState() { + return ( + + +
+ + + ); +} + +function DesktopLoadingState() { + return ( + +
+
+
+ + ); +} + +export function CatalystFlow() { + const { user, isLoading } = useUser(); + const isMobile = useCatalystLayout(); + const [step, setStep] = useState('arrival'); + const [authOpen, setAuthOpen] = useState(false); + + if (isLoading) { + return isMobile ? : ; + } + + if (user?.email) { + return isMobile ? ( + + ) : ( + + ); + } + + if (isMobile) { + if (step === 'auth') { + return ; + } + return setStep('auth')} />; + } + + return ( + <> + setAuthOpen(true)} /> + setAuthOpen(false)} + onSuccess={() => setAuthOpen(false)} + /> + + ); +} diff --git a/components/events/catalyst/CatalystLockup.tsx b/components/events/catalyst/CatalystLockup.tsx new file mode 100644 index 000000000..fe744c9ea --- /dev/null +++ b/components/events/catalyst/CatalystLockup.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Logo } from '@/components/ui/Logo'; +import { CatalystNyc } from './CatalystNyc'; + +const LOGO_SIZE = 25; +const NYC_HEIGHT = 15; +const GAP = 13; +const DIV_HEIGHT = 22; +const CAT_FONT = 21; +const CAT_GAP = 9; + +interface CatalystLockupProps { + theme?: 'onDark' | 'onLight'; +} + +export function CatalystLockup({ theme = 'onDark' }: Readonly) { + const onDark = theme === 'onDark'; + + return ( +
+ + + + Catalyst + + + + +
+ ); +} diff --git a/components/events/catalyst/CatalystLoggedInBody.tsx b/components/events/catalyst/CatalystLoggedInBody.tsx new file mode 100644 index 000000000..3b8a9a00a --- /dev/null +++ b/components/events/catalyst/CatalystLoggedInBody.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { signOut } from 'next-auth/react'; +import AnalyticsService from '@/services/analytics.service'; +import { AuthSharingService } from '@/services/auth-sharing.service'; +import { CATALYST_NYC_EVENT, formatCredit } from './constants'; + +const { footer, loggedIn, creditAmount, route } = CATALYST_NYC_EVENT; + +interface CatalystLoggedInBodyProps { + email: string; + variant: 'mobile' | 'desktop'; + showFooter?: boolean; +} + +export function CatalystLoggedInBody({ + email, + variant, + showFooter = false, +}: Readonly) { + const router = useRouter(); + const isMobile = variant === 'mobile'; + + // Sign out across both RH apps, then return here so the attendee can re-auth with the correct email. + const handleSignOut = () => { + AuthSharingService.removeSharedAuthToken(); + AnalyticsService.clearUserSession(); + signOut({ callbackUrl: route }); + }; + + return ( +
+ + +

+ Your {formatCredit(creditAmount)} {loggedIn.titleSuffix} +

+ +

{loggedIn.reviewNote}

+ +

{loggedIn.eyebrow}

+

{email}

+

{loggedIn.emailNote}

+ +
+ + +
+ + {showFooter &&

{footer}

} + + +
+ ); +} diff --git a/components/events/catalyst/CatalystLoggedInScreen.tsx b/components/events/catalyst/CatalystLoggedInScreen.tsx new file mode 100644 index 000000000..5d140f23d --- /dev/null +++ b/components/events/catalyst/CatalystLoggedInScreen.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { CATALYST_NYC_EVENT } from './constants'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystLoggedInBody } from './CatalystLoggedInBody'; +import { CatalystScreenShell } from './CatalystScreenShell'; + +const { footer } = CATALYST_NYC_EVENT; + +interface CatalystLoggedInScreenProps { + email: string; +} + +export function CatalystLoggedInScreen({ email }: Readonly) { + return ( + + + +
+ +
+ +
{footer}
+ + +
+ ); +} diff --git a/components/events/catalyst/CatalystNyc.tsx b/components/events/catalyst/CatalystNyc.tsx new file mode 100644 index 000000000..300ed1949 --- /dev/null +++ b/components/events/catalyst/CatalystNyc.tsx @@ -0,0 +1,76 @@ +const NYC_CELLS: ReadonlyArray = [ + [0, 0], + [12.69, 0], + [0, 3.17], + [3.17, 3.17], + [12.69, 3.17], + [0, 6.34], + [3.17, 6.34], + [12.69, 6.34], + [0, 9.52], + [6.34, 9.52], + [12.69, 9.52], + [0, 12.69], + [9.52, 12.69], + [12.69, 12.69], + [0, 15.86], + [9.52, 15.86], + [12.69, 15.86], + [0, 19.03], + [12.69, 19.03], + [18.89, 0], + [31.58, 0], + [18.89, 3.17], + [31.58, 3.17], + [22.06, 6.34], + [28.4, 6.34], + [25.23, 9.52], + [25.23, 12.69], + [25.23, 15.86], + [25.23, 19.03], + [40.95, 0], + [44.12, 0], + [47.29, 0], + [37.78, 3.17], + [50.46, 3.17], + [37.78, 6.34], + [37.78, 9.52], + [37.78, 12.69], + [37.78, 15.86], + [50.46, 15.86], + [40.95, 19.03], + [44.12, 19.03], + [47.29, 19.03], +]; + +const NATIVE_WIDTH = 53.1; +const NATIVE_HEIGHT = 21.6; + +interface CatalystNycProps { + fill: string; + height?: number; + className?: string; +} + +export function CatalystNyc({ + fill, + height = NATIVE_HEIGHT, + className, +}: Readonly) { + const width = (height * NATIVE_WIDTH) / NATIVE_HEIGHT; + return ( + + {NYC_CELLS.map(([x, y]) => ( + + ))} + + ); +} diff --git a/components/events/catalyst/CatalystScreenShell.tsx b/components/events/catalyst/CatalystScreenShell.tsx new file mode 100644 index 000000000..260f81bea --- /dev/null +++ b/components/events/catalyst/CatalystScreenShell.tsx @@ -0,0 +1,64 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { CatalystVioletBackdrop } from './CatalystVioletBackdrop'; + +interface CatalystScreenShellProps { + children: ReactNode; + contentLayout?: 'spread' | 'stack'; +} + +export function CatalystScreenShell({ + children, + contentLayout = 'stack', +}: Readonly) { + return ( +
+
+ +
+ {children} +
+
+ + +
+ ); +} diff --git a/components/events/catalyst/CatalystVioletBackdrop.tsx b/components/events/catalyst/CatalystVioletBackdrop.tsx new file mode 100644 index 000000000..801fe57f9 --- /dev/null +++ b/components/events/catalyst/CatalystVioletBackdrop.tsx @@ -0,0 +1,35 @@ +'use client'; + +export function CatalystVioletBackdrop() { + return ( + <> +