From 6e1450f178b62863802affa3e0ad4ff2f4ff1d0d Mon Sep 17 00:00:00 2001 From: Tyler Diorio <109099227+TylerDiorio@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:17:13 -0700 Subject: [PATCH 01/11] initial --- app/catalyst-nyc/page.tsx | 50 ++++++++++++ components/catalyst/CatalystAuthHeader.tsx | 52 ++++++++++++ components/catalyst/CatalystLockup.tsx | 81 +++++++++++++++++++ components/catalyst/CatalystSignupTrigger.tsx | 25 ++++++ components/catalyst/catalystTokens.ts | 21 +++++ components/modals/Auth/AuthModal.tsx | 24 +++++- components/modals/SignupModalContainer.tsx | 1 + contexts/AuthModalContext.tsx | 21 ++++- 8 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 app/catalyst-nyc/page.tsx create mode 100644 components/catalyst/CatalystAuthHeader.tsx create mode 100644 components/catalyst/CatalystLockup.tsx create mode 100644 components/catalyst/CatalystSignupTrigger.tsx create mode 100644 components/catalyst/catalystTokens.ts diff --git a/app/catalyst-nyc/page.tsx b/app/catalyst-nyc/page.tsx new file mode 100644 index 000000000..e1811a163 --- /dev/null +++ b/app/catalyst-nyc/page.tsx @@ -0,0 +1,50 @@ +import { Metadata } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/auth.config'; +import { buildOpenGraphMetadata } from '@/lib/metadata'; +import { handleTrendingRedirect } from '@/utils/navigation'; +import { LandingPage } from '@/components/landing/LandingPage'; +import { CatalystSignupTrigger } from '@/components/catalyst/CatalystSignupTrigger'; + +export const metadata: Metadata = { + ...buildOpenGraphMetadata({ + title: 'Catalyst NYC — Join ResearchHub', + description: + 'Catalyst NYC attendees: join ResearchHub and get $500 in Funding Credits to fund real research. Your credits earn yield daily in the ResearchHub Endowment.', + url: '/catalyst-nyc', + }), + // Event-specific QR landing; keep it out of search results. + robots: { + index: false, + follow: false, + nocache: true, + }, +}; + +export default async function CatalystNycPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const session = await getServerSession(authOptions); + + const resolvedSearchParams = await searchParams; + + const urlSearchParams = new URLSearchParams(); + Object.entries(resolvedSearchParams).forEach(([key, value]) => { + if (typeof value === 'string') { + urlSearchParams.set(key, value); + } else if (Array.isArray(value) && value.length > 0) { + urlSearchParams.set(key, value[0]); + } + }); + + handleTrendingRedirect(!!session?.user, urlSearchParams); + + return ( + <> + + + + ); +} diff --git a/components/catalyst/CatalystAuthHeader.tsx b/components/catalyst/CatalystAuthHeader.tsx new file mode 100644 index 000000000..b74e3bfa0 --- /dev/null +++ b/components/catalyst/CatalystAuthHeader.tsx @@ -0,0 +1,52 @@ +import Link from 'next/link'; +import { CatalystLockup } from './CatalystLockup'; +import { CATALYST_COLORS, CATALYST_TAGLINE } from './catalystTokens'; + +/** + * Catalyst NYC face for the shared auth modal. Purely presentational: the auth + * flow underneath is unchanged. Crediting the $500 is handled manually on the + * backend against a registration email whitelist, hence the email reminder. + */ +export function CatalystAuthHeader() { + return ( +
+ + +
+

+ {CATALYST_TAGLINE} +

+

+ $500 in Funding Credits, on us. +

+

+ Sign up to receive $500 to fund real research. Your + credits live in the{' '} + + ResearchHub Endowment + {' '} + and earn yield daily — your balance grows every day. +

+
+ +

+ Use the email from your Catalyst NYC registration so we can credit your account. +

+
+ ); +} diff --git a/components/catalyst/CatalystLockup.tsx b/components/catalyst/CatalystLockup.tsx new file mode 100644 index 000000000..9392b4457 --- /dev/null +++ b/components/catalyst/CatalystLockup.tsx @@ -0,0 +1,81 @@ +import { Logo } from '@/components/ui/Logo'; +import { CATALYST_COLORS } from './catalystTokens'; + +// 5x7 dot-matrix glyphs for "NYC", rebuilt as live pixels from the Catalyst NYC +// brand extract so the wordmark stays crisp at any size without an image asset. +const GLYPHS: Record = { + N: ['10001', '11001', '11001', '10101', '10011', '10011', '10001'], + Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], + C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], +}; + +const CELL = 3; +const CELL_GAP = 1; + +function PixelNYC() { + return ( + + {'NYC'.split('').map((char, charIndex) => { + const rows = GLYPHS[char] ?? []; + return ( + + {rows.flatMap((row, rowIndex) => + row.split('').map((bit, colIndex) => ( + + )) + )} + + ); + })} + + ); +} + +interface CatalystLockupProps { + className?: string; +} + +export function CatalystLockup({ className }: CatalystLockupProps) { + return ( +
+ + + + + Catalyst + + + +
+ ); +} diff --git a/components/catalyst/CatalystSignupTrigger.tsx b/components/catalyst/CatalystSignupTrigger.tsx new file mode 100644 index 000000000..b19a704d8 --- /dev/null +++ b/components/catalyst/CatalystSignupTrigger.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useAuthModalContext } from '@/contexts/AuthModalContext'; +import { useUser } from '@/contexts/UserContext'; + +/** + * Renders nothing; auto-opens the Catalyst-branded auth modal once for + * logged-out visitors landing on /catalyst-nyc (e.g. from the conference QR + * code). Logged-in users are redirected away by the page, so we skip them to + * avoid flashing a signup modal before the redirect lands. + */ +export function CatalystSignupTrigger() { + const { showAuthModal } = useAuthModalContext(); + const { user, isLoading } = useUser(); + const hasOpenedRef = useRef(false); + + useEffect(() => { + if (isLoading || user || hasOpenedRef.current) return; + hasOpenedRef.current = true; + showAuthModal(undefined, { variant: 'catalyst' }); + }, [user, isLoading, showAuthModal]); + + return null; +} diff --git a/components/catalyst/catalystTokens.ts b/components/catalyst/catalystTokens.ts new file mode 100644 index 000000000..6a21ace91 --- /dev/null +++ b/components/catalyst/catalystTokens.ts @@ -0,0 +1,21 @@ +/** + * Catalyst NYC conference palette, sampled from the event brand extract. These + * violet accents sit on top of ResearchHub's otherwise light auth surfaces so + * the in-person attendee experience reads as co-branded without forking the + * whole theme. + */ +export const CATALYST_COLORS = { + glowViolet: '#7B43BE', + royalViolet: '#5A2DB0', + deepIndigo: '#3A1F86', + twilight: '#20104E', + plumBlack: '#0C0720', + accentViolet: '#7C3AED', + white: '#FFFFFF', +} as const; + +/** Signature radial violet field from the brand extract (light-source top-left). */ +export const CATALYST_GRADIENT = + 'radial-gradient(98% 78% at 22% 26%, #7B43BE 0%, #5A2DB0 26%, #3A1F86 48%, #20104E 72%, #0C0720 100%)'; + +export const CATALYST_TAGLINE = 'The Future of Translational Science'; diff --git a/components/modals/Auth/AuthModal.tsx b/components/modals/Auth/AuthModal.tsx index 56ce4e168..5cd4101be 100644 --- a/components/modals/Auth/AuthModal.tsx +++ b/components/modals/Auth/AuthModal.tsx @@ -2,17 +2,28 @@ import { Button } from '@/components/ui/Button'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faXmark } from '@fortawesome/pro-light-svg-icons'; import AuthContent from '@/components/Auth/AuthContent'; +import { CatalystAuthHeader } from '@/components/catalyst/CatalystAuthHeader'; +import type { AuthModalVariant } from '@/contexts/AuthModalContext'; interface AuthModalProps { isOpen: boolean; onClose: () => void; onSuccess?: () => void; initialError?: string | null; + variant?: AuthModalVariant; } -export default function AuthModal({ isOpen, onClose, onSuccess, initialError }: AuthModalProps) { +export default function AuthModal({ + isOpen, + onClose, + onSuccess, + initialError, + variant = 'default', +}: AuthModalProps) { if (!isOpen) return null; + const isCatalyst = variant === 'catalyst'; + const handleBackgroundClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); @@ -21,10 +32,14 @@ export default function AuthModal({ isOpen, onClose, onSuccess, initialError }: return (
-
+
+ {isCatalyst && } +
diff --git a/components/modals/SignupModalContainer.tsx b/components/modals/SignupModalContainer.tsx index b56d9f869..da12f791e 100644 --- a/components/modals/SignupModalContainer.tsx +++ b/components/modals/SignupModalContainer.tsx @@ -10,6 +10,7 @@ const EXCLUDED_PATHS = [ '/', // landing page '/referral/join', // referral join page '/referral/join/apply-referral-code', // referral apply referral code page + '/catalyst-nyc', // Catalyst NYC QR landing opens its own branded auth modal // add any other paths where we don't want the modal to show ]; diff --git a/contexts/AuthModalContext.tsx b/contexts/AuthModalContext.tsx index 6cd0720dc..1225df45f 100644 --- a/contexts/AuthModalContext.tsx +++ b/contexts/AuthModalContext.tsx @@ -6,8 +6,15 @@ import { useSession } from 'next-auth/react'; import { ReactNode } from 'react'; import AnalyticsService, { LogEvent } from '@/services/analytics.service'; +/** Cosmetic theming for the auth modal. 'catalyst' co-brands it for Catalyst NYC. */ +export type AuthModalVariant = 'default' | 'catalyst'; + +interface ShowAuthModalOptions { + variant?: AuthModalVariant; +} + interface AuthModalContextType { - showAuthModal: (onSuccess?: () => void) => void; + showAuthModal: (onSuccess?: () => void, options?: ShowAuthModalOptions) => void; hideAuthModal: () => void; } @@ -23,16 +30,19 @@ export function useAuthModalContext() { export function AuthModalProvider({ children }: { children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false); + const [variant, setVariant] = useState('default'); const [pendingAction, setPendingAction] = useState<(() => void) | undefined>(); - const showAuthModal = useCallback((onSuccess?: () => void) => { + const showAuthModal = useCallback((onSuccess?: () => void, options?: ShowAuthModalOptions) => { setIsOpen(true); + setVariant(options?.variant ?? 'default'); AnalyticsService.logEvent(LogEvent.AUTH_MODAL_OPENED); setPendingAction(() => onSuccess); }, []); const hideAuthModal = useCallback(() => { setIsOpen(false); + setVariant('default'); setPendingAction(undefined); AnalyticsService.logEvent(LogEvent.AUTH_MODAL_CLOSED); }, []); @@ -47,7 +57,12 @@ export function AuthModalProvider({ children }: { children: React.ReactNode }) { return ( {children} - + ); } From d3b946c0e8639bf702f1f34b7b0ecf92fa172c3c Mon Sep 17 00:00:00 2001 From: Tyler Diorio <109099227+TylerDiorio@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:37:33 -0700 Subject: [PATCH 02/11] initial --- app/catalyst-nyc/page.tsx | 57 +-- components/catalyst/CatalystArrivalScreen.tsx | 250 ++++++++++ components/catalyst/CatalystAuthHeader.tsx | 52 --- components/catalyst/CatalystAuthScreen.tsx | 437 ++++++++++++++++++ components/catalyst/CatalystFlow.tsx | 21 + components/catalyst/CatalystLockup.tsx | 81 ---- components/catalyst/CatalystNyc.tsx | 80 ++++ components/catalyst/CatalystSignupTrigger.tsx | 25 - components/catalyst/catalystTokens.ts | 21 - components/modals/Auth/AuthModal.tsx | 24 +- contexts/AuthModalContext.tsx | 21 +- 11 files changed, 807 insertions(+), 262 deletions(-) create mode 100644 components/catalyst/CatalystArrivalScreen.tsx delete mode 100644 components/catalyst/CatalystAuthHeader.tsx create mode 100644 components/catalyst/CatalystAuthScreen.tsx create mode 100644 components/catalyst/CatalystFlow.tsx delete mode 100644 components/catalyst/CatalystLockup.tsx create mode 100644 components/catalyst/CatalystNyc.tsx delete mode 100644 components/catalyst/CatalystSignupTrigger.tsx delete mode 100644 components/catalyst/catalystTokens.ts diff --git a/app/catalyst-nyc/page.tsx b/app/catalyst-nyc/page.tsx index e1811a163..d595633b3 100644 --- a/app/catalyst-nyc/page.tsx +++ b/app/catalyst-nyc/page.tsx @@ -1,50 +1,19 @@ -import { Metadata } from 'next'; -import { getServerSession } from 'next-auth/next'; -import { authOptions } from '@/app/api/auth/[...nextauth]/auth.config'; -import { buildOpenGraphMetadata } from '@/lib/metadata'; -import { handleTrendingRedirect } from '@/utils/navigation'; -import { LandingPage } from '@/components/landing/LandingPage'; -import { CatalystSignupTrigger } from '@/components/catalyst/CatalystSignupTrigger'; +import type { Metadata } from 'next'; +import { CatalystFlow } from '@/components/catalyst/CatalystFlow'; export const metadata: Metadata = { - ...buildOpenGraphMetadata({ - title: 'Catalyst NYC — Join ResearchHub', - description: - 'Catalyst NYC attendees: join ResearchHub and get $500 in Funding Credits to fund real research. Your credits earn yield daily in the ResearchHub Endowment.', - url: '/catalyst-nyc', - }), + title: 'Catalyst NYC — Join ResearchHub', + description: + 'Catalyst NYC attendees: claim $500 in ResearchCoin to fund science. Sign up with the email you registered with.', // Event-specific QR landing; keep it out of search results. - robots: { - index: false, - follow: false, - nocache: true, - }, + robots: { index: false, follow: false, nocache: true }, }; -export default async function CatalystNycPage({ - searchParams, -}: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}) { - const session = await getServerSession(authOptions); - - const resolvedSearchParams = await searchParams; - - const urlSearchParams = new URLSearchParams(); - Object.entries(resolvedSearchParams).forEach(([key, value]) => { - if (typeof value === 'string') { - urlSearchParams.set(key, value); - } else if (Array.isArray(value) && value.length > 0) { - urlSearchParams.set(key, value[0]); - } - }); - - handleTrendingRedirect(!!session?.user, urlSearchParams); - - return ( - <> - - - - ); +/** + * Standalone full-screen mobile flow for the Catalyst NYC QR code. Renders + * outside the app chrome (no PageLayout) so the designed arrival/auth screens + * own the full viewport. + */ +export default function CatalystNycPage() { + return ; } diff --git a/components/catalyst/CatalystArrivalScreen.tsx b/components/catalyst/CatalystArrivalScreen.tsx new file mode 100644 index 000000000..e2b11cbde --- /dev/null +++ b/components/catalyst/CatalystArrivalScreen.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Logo } from '@/components/ui/Logo'; +import { CatalystNyc } from './CatalystNyc'; + +// Simple (non-compounded) yield: 56.1% of the original $500 each year, years 0..10. +const RATE = 0.561; +const BASE = 500; +const MAX = BASE * (1 + RATE * 10); +const BAR_COUNT = 11; + +const HEADLINE = "We want you to fund science — here's some ResearchCoin to get started."; + +function money(n: number): string { + return '$' + Math.round(n).toLocaleString('en-US'); +} + +interface CatalystArrivalScreenProps { + onClaim: () => void; +} + +export function CatalystArrivalScreen({ onClaim }: CatalystArrivalScreenProps) { + const [yr, setYr] = useState(0); + const holdRef = useRef(0); + + useEffect(() => { + const id = setInterval(() => { + setYr((prev) => { + // Linger a few ticks on the starting balance before climbing. + if (prev === 0 && holdRef.current < 3) { + holdRef.current += 1; + return prev; + } + holdRef.current = 0; + return prev >= 10 ? 0 : prev + 1; + }); + }, 900); + return () => clearInterval(id); + }, []); + + const value = BASE * (1 + RATE * yr); + const yrText = + (yr === 0 ? 'Your starting balance today' : `After ${yr} ${yr === 1 ? 'year' : 'years'}`) + + ' · 56.1% / yr'; + + return ( +
+
+
+
+
+
+ + + + Catalyst + + +
+ +
+
{money(value)}
+
{yrText}
+
+ {Array.from({ length: BAR_COUNT }).map((_, i) => { + const barValue = BASE * (1 + RATE * i); + const height = Math.max(3, (barValue / MAX) * 100); + const active = i <= yr; + return ( + + ); + })} +
+
+ Now + 5 years + 10 years +
+
+ +

{HEADLINE}

+ +
+ +
Catalyst NYC promotional event
+
+
+
+ + +
+ ); +} diff --git a/components/catalyst/CatalystAuthHeader.tsx b/components/catalyst/CatalystAuthHeader.tsx deleted file mode 100644 index b74e3bfa0..000000000 --- a/components/catalyst/CatalystAuthHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import Link from 'next/link'; -import { CatalystLockup } from './CatalystLockup'; -import { CATALYST_COLORS, CATALYST_TAGLINE } from './catalystTokens'; - -/** - * Catalyst NYC face for the shared auth modal. Purely presentational: the auth - * flow underneath is unchanged. Crediting the $500 is handled manually on the - * backend against a registration email whitelist, hence the email reminder. - */ -export function CatalystAuthHeader() { - return ( -
- - -
-

- {CATALYST_TAGLINE} -

-

- $500 in Funding Credits, on us. -

-

- Sign up to receive $500 to fund real research. Your - credits live in the{' '} - - ResearchHub Endowment - {' '} - and earn yield daily — your balance grows every day. -

-
- -

- Use the email from your Catalyst NYC registration so we can credit your account. -

-
- ); -} diff --git a/components/catalyst/CatalystAuthScreen.tsx b/components/catalyst/CatalystAuthScreen.tsx new file mode 100644 index 000000000..63385450c --- /dev/null +++ b/components/catalyst/CatalystAuthScreen.tsx @@ -0,0 +1,437 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { signIn } from 'next-auth/react'; +import Login from '@/components/Auth/screens/Login'; +import Signup from '@/components/Auth/screens/Signup'; +import VerifyEmail from '@/components/Auth/screens/VerifyEmail'; +import ForgotPassword from '@/components/Auth/screens/ForgotPassword'; +import { AuthService } from '@/services/auth.service'; +import { ApiError } from '@/services/types/api'; +import { isValidEmail } from '@/utils/validation'; +import { Logo } from '@/components/ui/Logo'; +import { CatalystNyc } from './CatalystNyc'; + +type Screen = 'entry' | 'login' | 'signup' | 'verify' | 'forgot'; + +function GoogleGlyph() { + return ( + + ); +} + +function InfoGlyph() { + return ( + + ); +} + +export function CatalystAuthScreen() { + const router = useRouter(); + const [screen, setScreen] = useState('entry'); + const [email, setEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const goToFeed = () => router.push('/'); + + const handleContinue = async (e: React.FormEvent) => { + e.preventDefault(); + if (!isValidEmail(email)) { + setError('Please enter a valid email'); + return; + } + setError(null); + setIsLoading(true); + try { + const res = await AuthService.checkAccount(email); + if (res.exists) { + if (res.auth === 'google') { + signIn('google', { callbackUrl: '/' }); + } else if (res.is_verified) { + setScreen('login'); + } else { + setError('Please verify your email before logging in'); + } + } else { + setScreen('signup'); + } + } catch (err) { + setError(err instanceof ApiError ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleGoogle = () => { + signIn('google', { callbackUrl: '/' }); + }; + + const goEntry = () => { + setError(null); + setScreen('entry'); + }; + + const sharedProps = { email, setEmail, isLoading, error, setError, onClose: goToFeed }; + + return ( +
+
+
+
+
+
+ + + + Catalyst + + +
+ +
+ {screen === 'entry' ? ( + <> +

+ Sign-up to +
+ claim your $500 +

+ +
+ + setEmail(e.target.value)} + /> + {error &&

{error}

} + +
+ +
+ + OR + +
+ + + +

+ + + Use the same email you registered with. + +

+ + ) : ( +
+ {screen === 'login' && ( + { + setError(null); + setScreen('forgot'); + }} + onSuccess={goToFeed} + modalView + /> + )} + {screen === 'signup' && ( + setScreen('verify')} + modalView + /> + )} + {screen === 'verify' && } + {screen === 'forgot' && ( + { + setError(null); + setScreen('login'); + }} + modalView + /> + )} +
+ )} +
+ +
Catalyst NYC promotional event
+
+
+ + +
+ ); +} diff --git a/components/catalyst/CatalystFlow.tsx b/components/catalyst/CatalystFlow.tsx new file mode 100644 index 000000000..154b2bb41 --- /dev/null +++ b/components/catalyst/CatalystFlow.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useState } from 'react'; +import { CatalystArrivalScreen } from './CatalystArrivalScreen'; +import { CatalystAuthScreen } from './CatalystAuthScreen'; + +type Step = 'arrival' | 'auth'; + +/** + * Catalyst NYC QR sign-up flow: the violet arrival screen (the offer) advances + * to the white email-first auth screen when the attendee taps "Claim my $500". + */ +export function CatalystFlow() { + const [step, setStep] = useState('arrival'); + + if (step === 'auth') { + return ; + } + + return setStep('auth')} />; +} diff --git a/components/catalyst/CatalystLockup.tsx b/components/catalyst/CatalystLockup.tsx deleted file mode 100644 index 9392b4457..000000000 --- a/components/catalyst/CatalystLockup.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Logo } from '@/components/ui/Logo'; -import { CATALYST_COLORS } from './catalystTokens'; - -// 5x7 dot-matrix glyphs for "NYC", rebuilt as live pixels from the Catalyst NYC -// brand extract so the wordmark stays crisp at any size without an image asset. -const GLYPHS: Record = { - N: ['10001', '11001', '11001', '10101', '10011', '10011', '10001'], - Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], - C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], -}; - -const CELL = 3; -const CELL_GAP = 1; - -function PixelNYC() { - return ( - - {'NYC'.split('').map((char, charIndex) => { - const rows = GLYPHS[char] ?? []; - return ( - - {rows.flatMap((row, rowIndex) => - row.split('').map((bit, colIndex) => ( - - )) - )} - - ); - })} - - ); -} - -interface CatalystLockupProps { - className?: string; -} - -export function CatalystLockup({ className }: CatalystLockupProps) { - return ( -
- - - - - Catalyst - - - -
- ); -} diff --git a/components/catalyst/CatalystNyc.tsx b/components/catalyst/CatalystNyc.tsx new file mode 100644 index 000000000..d21ebb044 --- /dev/null +++ b/components/catalyst/CatalystNyc.tsx @@ -0,0 +1,80 @@ +// Dot-matrix "NYC" mark, reproduced exactly from the Catalyst NYC design files +// (5x7-style glyphs as 2.6px rounded cells). Fill is configurable so the mark can +// render white on the violet arrival field and #7C3AED on the white auth screen. +const NYC_CELLS: ReadonlyArray = [ + // N + [0.0, 0.0], + [12.69, 0.0], + [0.0, 3.17], + [3.17, 3.17], + [12.69, 3.17], + [0.0, 6.34], + [3.17, 6.34], + [12.69, 6.34], + [0.0, 9.52], + [6.34, 9.52], + [12.69, 9.52], + [0.0, 12.69], + [9.52, 12.69], + [12.69, 12.69], + [0.0, 15.86], + [9.52, 15.86], + [12.69, 15.86], + [0.0, 19.03], + [12.69, 19.03], + // Y + [18.89, 0.0], + [31.58, 0.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], + // C + [40.95, 0.0], + [44.12, 0.0], + [47.29, 0.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 { + /** Cell fill color. White on the arrival field, #7C3AED on the auth screen. */ + fill: string; + /** Rendered height in px; width scales to keep the 53.1:21.6 aspect ratio. */ + height?: number; + className?: string; +} + +export function CatalystNyc({ fill, height = NATIVE_HEIGHT, className }: CatalystNycProps) { + const width = (height * NATIVE_WIDTH) / NATIVE_HEIGHT; + return ( + + {NYC_CELLS.map(([x, y]) => ( + + ))} + + ); +} diff --git a/components/catalyst/CatalystSignupTrigger.tsx b/components/catalyst/CatalystSignupTrigger.tsx deleted file mode 100644 index b19a704d8..000000000 --- a/components/catalyst/CatalystSignupTrigger.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; - -import { useEffect, useRef } from 'react'; -import { useAuthModalContext } from '@/contexts/AuthModalContext'; -import { useUser } from '@/contexts/UserContext'; - -/** - * Renders nothing; auto-opens the Catalyst-branded auth modal once for - * logged-out visitors landing on /catalyst-nyc (e.g. from the conference QR - * code). Logged-in users are redirected away by the page, so we skip them to - * avoid flashing a signup modal before the redirect lands. - */ -export function CatalystSignupTrigger() { - const { showAuthModal } = useAuthModalContext(); - const { user, isLoading } = useUser(); - const hasOpenedRef = useRef(false); - - useEffect(() => { - if (isLoading || user || hasOpenedRef.current) return; - hasOpenedRef.current = true; - showAuthModal(undefined, { variant: 'catalyst' }); - }, [user, isLoading, showAuthModal]); - - return null; -} diff --git a/components/catalyst/catalystTokens.ts b/components/catalyst/catalystTokens.ts deleted file mode 100644 index 6a21ace91..000000000 --- a/components/catalyst/catalystTokens.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Catalyst NYC conference palette, sampled from the event brand extract. These - * violet accents sit on top of ResearchHub's otherwise light auth surfaces so - * the in-person attendee experience reads as co-branded without forking the - * whole theme. - */ -export const CATALYST_COLORS = { - glowViolet: '#7B43BE', - royalViolet: '#5A2DB0', - deepIndigo: '#3A1F86', - twilight: '#20104E', - plumBlack: '#0C0720', - accentViolet: '#7C3AED', - white: '#FFFFFF', -} as const; - -/** Signature radial violet field from the brand extract (light-source top-left). */ -export const CATALYST_GRADIENT = - 'radial-gradient(98% 78% at 22% 26%, #7B43BE 0%, #5A2DB0 26%, #3A1F86 48%, #20104E 72%, #0C0720 100%)'; - -export const CATALYST_TAGLINE = 'The Future of Translational Science'; diff --git a/components/modals/Auth/AuthModal.tsx b/components/modals/Auth/AuthModal.tsx index 5cd4101be..56ce4e168 100644 --- a/components/modals/Auth/AuthModal.tsx +++ b/components/modals/Auth/AuthModal.tsx @@ -2,28 +2,17 @@ import { Button } from '@/components/ui/Button'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faXmark } from '@fortawesome/pro-light-svg-icons'; import AuthContent from '@/components/Auth/AuthContent'; -import { CatalystAuthHeader } from '@/components/catalyst/CatalystAuthHeader'; -import type { AuthModalVariant } from '@/contexts/AuthModalContext'; interface AuthModalProps { isOpen: boolean; onClose: () => void; onSuccess?: () => void; initialError?: string | null; - variant?: AuthModalVariant; } -export default function AuthModal({ - isOpen, - onClose, - onSuccess, - initialError, - variant = 'default', -}: AuthModalProps) { +export default function AuthModal({ isOpen, onClose, onSuccess, initialError }: AuthModalProps) { if (!isOpen) return null; - const isCatalyst = variant === 'catalyst'; - const handleBackgroundClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); @@ -32,14 +21,10 @@ export default function AuthModal({ return (
-
+
- {isCatalyst && } -
diff --git a/contexts/AuthModalContext.tsx b/contexts/AuthModalContext.tsx index 1225df45f..6cd0720dc 100644 --- a/contexts/AuthModalContext.tsx +++ b/contexts/AuthModalContext.tsx @@ -6,15 +6,8 @@ import { useSession } from 'next-auth/react'; import { ReactNode } from 'react'; import AnalyticsService, { LogEvent } from '@/services/analytics.service'; -/** Cosmetic theming for the auth modal. 'catalyst' co-brands it for Catalyst NYC. */ -export type AuthModalVariant = 'default' | 'catalyst'; - -interface ShowAuthModalOptions { - variant?: AuthModalVariant; -} - interface AuthModalContextType { - showAuthModal: (onSuccess?: () => void, options?: ShowAuthModalOptions) => void; + showAuthModal: (onSuccess?: () => void) => void; hideAuthModal: () => void; } @@ -30,19 +23,16 @@ export function useAuthModalContext() { export function AuthModalProvider({ children }: { children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false); - const [variant, setVariant] = useState('default'); const [pendingAction, setPendingAction] = useState<(() => void) | undefined>(); - const showAuthModal = useCallback((onSuccess?: () => void, options?: ShowAuthModalOptions) => { + const showAuthModal = useCallback((onSuccess?: () => void) => { setIsOpen(true); - setVariant(options?.variant ?? 'default'); AnalyticsService.logEvent(LogEvent.AUTH_MODAL_OPENED); setPendingAction(() => onSuccess); }, []); const hideAuthModal = useCallback(() => { setIsOpen(false); - setVariant('default'); setPendingAction(undefined); AnalyticsService.logEvent(LogEvent.AUTH_MODAL_CLOSED); }, []); @@ -57,12 +47,7 @@ export function AuthModalProvider({ children }: { children: React.ReactNode }) { return ( {children} - + ); } From 0adc30e87cb552ab34b25e25917c6e9fd0dbf276 Mon Sep 17 00:00:00 2001 From: Tyler Diorio <109099227+TylerDiorio@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:41:31 -0700 Subject: [PATCH 03/11] thermonuclear touchup --- app/catalyst-nyc/page.tsx | 8 +- components/catalyst/CatalystArrivalScreen.tsx | 250 --------------- components/catalyst/CatalystFlow.tsx | 21 -- .../events/catalyst/CatalystArrivalScreen.tsx | 168 ++++++++++ .../catalyst/CatalystAuthScreen.tsx | 303 +++++++----------- components/events/catalyst/CatalystFlow.tsx | 68 ++++ components/events/catalyst/CatalystLockup.tsx | 57 ++++ .../catalyst/CatalystLoggedInScreen.tsx | 125 ++++++++ .../{ => events}/catalyst/CatalystNyc.tsx | 6 +- .../events/catalyst/CatalystScreenShell.tsx | 92 ++++++ components/events/catalyst/constants.ts | 51 +++ components/modals/SignupModalContainer.tsx | 2 +- 12 files changed, 687 insertions(+), 464 deletions(-) delete mode 100644 components/catalyst/CatalystArrivalScreen.tsx delete mode 100644 components/catalyst/CatalystFlow.tsx create mode 100644 components/events/catalyst/CatalystArrivalScreen.tsx rename components/{ => events}/catalyst/CatalystAuthScreen.tsx (54%) create mode 100644 components/events/catalyst/CatalystFlow.tsx create mode 100644 components/events/catalyst/CatalystLockup.tsx create mode 100644 components/events/catalyst/CatalystLoggedInScreen.tsx rename components/{ => events}/catalyst/CatalystNyc.tsx (78%) create mode 100644 components/events/catalyst/CatalystScreenShell.tsx create mode 100644 components/events/catalyst/constants.ts diff --git a/app/catalyst-nyc/page.tsx b/app/catalyst-nyc/page.tsx index d595633b3..a77bfe290 100644 --- a/app/catalyst-nyc/page.tsx +++ b/app/catalyst-nyc/page.tsx @@ -1,10 +1,10 @@ import type { Metadata } from 'next'; -import { CatalystFlow } from '@/components/catalyst/CatalystFlow'; +import { CatalystFlow } from '@/components/events/catalyst/CatalystFlow'; +import { CATALYST_NYC_EVENT } from '@/components/events/catalyst/constants'; export const metadata: Metadata = { - title: 'Catalyst NYC — Join ResearchHub', - description: - 'Catalyst NYC attendees: claim $500 in ResearchCoin to fund science. Sign up with the email you registered with.', + title: CATALYST_NYC_EVENT.metadata.title, + description: CATALYST_NYC_EVENT.metadata.description, // Event-specific QR landing; keep it out of search results. robots: { index: false, follow: false, nocache: true }, }; diff --git a/components/catalyst/CatalystArrivalScreen.tsx b/components/catalyst/CatalystArrivalScreen.tsx deleted file mode 100644 index e2b11cbde..000000000 --- a/components/catalyst/CatalystArrivalScreen.tsx +++ /dev/null @@ -1,250 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { Logo } from '@/components/ui/Logo'; -import { CatalystNyc } from './CatalystNyc'; - -// Simple (non-compounded) yield: 56.1% of the original $500 each year, years 0..10. -const RATE = 0.561; -const BASE = 500; -const MAX = BASE * (1 + RATE * 10); -const BAR_COUNT = 11; - -const HEADLINE = "We want you to fund science — here's some ResearchCoin to get started."; - -function money(n: number): string { - return '$' + Math.round(n).toLocaleString('en-US'); -} - -interface CatalystArrivalScreenProps { - onClaim: () => void; -} - -export function CatalystArrivalScreen({ onClaim }: CatalystArrivalScreenProps) { - const [yr, setYr] = useState(0); - const holdRef = useRef(0); - - useEffect(() => { - const id = setInterval(() => { - setYr((prev) => { - // Linger a few ticks on the starting balance before climbing. - if (prev === 0 && holdRef.current < 3) { - holdRef.current += 1; - return prev; - } - holdRef.current = 0; - return prev >= 10 ? 0 : prev + 1; - }); - }, 900); - return () => clearInterval(id); - }, []); - - const value = BASE * (1 + RATE * yr); - const yrText = - (yr === 0 ? 'Your starting balance today' : `After ${yr} ${yr === 1 ? 'year' : 'years'}`) + - ' · 56.1% / yr'; - - return ( -
-
-
-
-
-
- - - - Catalyst - - -
- -
-
{money(value)}
-
{yrText}
-
- {Array.from({ length: BAR_COUNT }).map((_, i) => { - const barValue = BASE * (1 + RATE * i); - const height = Math.max(3, (barValue / MAX) * 100); - const active = i <= yr; - return ( - - ); - })} -
-
- Now - 5 years - 10 years -
-
- -

{HEADLINE}

- -
- -
Catalyst NYC promotional event
-
-
-
- - -
- ); -} diff --git a/components/catalyst/CatalystFlow.tsx b/components/catalyst/CatalystFlow.tsx deleted file mode 100644 index 154b2bb41..000000000 --- a/components/catalyst/CatalystFlow.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { CatalystArrivalScreen } from './CatalystArrivalScreen'; -import { CatalystAuthScreen } from './CatalystAuthScreen'; - -type Step = 'arrival' | 'auth'; - -/** - * Catalyst NYC QR sign-up flow: the violet arrival screen (the offer) advances - * to the white email-first auth screen when the attendee taps "Claim my $500". - */ -export function CatalystFlow() { - const [step, setStep] = useState('arrival'); - - if (step === 'auth') { - return ; - } - - return setStep('auth')} />; -} diff --git a/components/events/catalyst/CatalystArrivalScreen.tsx b/components/events/catalyst/CatalystArrivalScreen.tsx new file mode 100644 index 000000000..e7a81133c --- /dev/null +++ b/components/events/catalyst/CatalystArrivalScreen.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { CATALYST_NYC_EVENT, formatMoney } from './constants'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystScreenShell } from './CatalystScreenShell'; + +const { + creditAmount, + yieldRate, + yieldLabel, + barCount, + maxYears, + animationIntervalMs, + arrival, + footer, +} = CATALYST_NYC_EVENT; + +const MAX = creditAmount * (1 + yieldRate * maxYears); + +interface CatalystArrivalScreenProps { + onClaim: () => void; +} + +export function CatalystArrivalScreen({ onClaim }: CatalystArrivalScreenProps) { + const [yr, setYr] = useState(0); + const holdRef = useRef(0); + + useEffect(() => { + const prefersReducedMotion = window.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 yrText = + (yr === 0 ? 'Your starting balance today' : `After ${yr} ${yr === 1 ? 'year' : 'years'}`) + + ` · ${yieldLabel}`; + + return ( + + + +
+
{formatMoney(value)}
+
{yrText}
+
+ {Array.from({ length: barCount }).map((_, i) => { + const barValue = creditAmount * (1 + yieldRate * i); + const height = Math.max(3, (barValue / MAX) * 100); + const active = i <= yr; + return ( +
+
+ Now + 5 years + 10 years +
+
+ +

{arrival.headline}

+ +
+ +
{footer}
+
+ + +
+ ); +} diff --git a/components/catalyst/CatalystAuthScreen.tsx b/components/events/catalyst/CatalystAuthScreen.tsx similarity index 54% rename from components/catalyst/CatalystAuthScreen.tsx rename to components/events/catalyst/CatalystAuthScreen.tsx index 63385450c..420af3b22 100644 --- a/components/catalyst/CatalystAuthScreen.tsx +++ b/components/events/catalyst/CatalystAuthScreen.tsx @@ -10,10 +10,15 @@ import ForgotPassword from '@/components/Auth/screens/ForgotPassword'; import { AuthService } from '@/services/auth.service'; import { ApiError } from '@/services/types/api'; import { isValidEmail } from '@/utils/validation'; -import { Logo } from '@/components/ui/Logo'; -import { CatalystNyc } from './CatalystNyc'; +import { useAutoFocus } from '@/hooks/useAutoFocus'; +import { CATALYST_NYC_EVENT } from './constants'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystScreenShell } from './CatalystScreenShell'; type Screen = 'entry' | 'login' | 'signup' | 'verify' | 'forgot'; +type DeeperScreen = Exclude; + +const { footer, auth } = CATALYST_NYC_EVENT; function GoogleGlyph() { return ( @@ -60,6 +65,7 @@ function InfoGlyph() { export function CatalystAuthScreen() { const router = useRouter(); + const emailInputRef = useAutoFocus(true); const [screen, setScreen] = useState('entry'); const [email, setEmail] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -106,192 +112,112 @@ export function CatalystAuthScreen() { const sharedProps = { email, setEmail, isLoading, error, setError, onClose: goToFeed }; + const renderDeeperScreen = (deeperScreen: DeeperScreen) => { + switch (deeperScreen) { + case 'login': + return ( + { + setError(null); + setScreen('forgot'); + }} + onSuccess={goToFeed} + modalView + /> + ); + case 'signup': + return ( + setScreen('verify')} + modalView + /> + ); + case 'verify': + return ; + case 'forgot': + return ( + { + setError(null); + setScreen('login'); + }} + modalView + /> + ); + default: { + const _exhaustive: never = deeperScreen; + return _exhaustive; + } + } + }; + return ( -
-
-
-
-
-
- - - - Catalyst - - -
+ + -
- {screen === 'entry' ? ( - <> -

- Sign-up to -
- claim your $500 -

+
+ {screen === 'entry' ? ( + <> +

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

-
- - setEmail(e.target.value)} - /> - {error &&

{error}

} - -
+
+ + setEmail(e.target.value)} + /> + {error &&

{error}

} + +
-
- - OR - -
+
+ + OR + +
- + -

- - - Use the same email you registered with. - -

- - ) : ( -
- {screen === 'login' && ( - { - setError(null); - setScreen('forgot'); - }} - onSuccess={goToFeed} - modalView - /> - )} - {screen === 'signup' && ( - setScreen('verify')} - modalView - /> - )} - {screen === 'verify' && } - {screen === 'forgot' && ( - { - setError(null); - setScreen('login'); - }} - modalView - /> - )} -
- )} -
+

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

+ + ) : ( +
{renderDeeperScreen(screen)}
+ )} +
-
Catalyst NYC promotional event
-
-
+
{footer}
-
+ ); } diff --git a/components/events/catalyst/CatalystFlow.tsx b/components/events/catalyst/CatalystFlow.tsx new file mode 100644 index 000000000..2a7ac8bb2 --- /dev/null +++ b/components/events/catalyst/CatalystFlow.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useState } from 'react'; +import { useUser } from '@/contexts/UserContext'; +import { CatalystArrivalScreen } from './CatalystArrivalScreen'; +import { CatalystAuthScreen } from './CatalystAuthScreen'; +import { CatalystLoggedInScreen } from './CatalystLoggedInScreen'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystScreenShell } from './CatalystScreenShell'; + +type Step = 'arrival' | 'auth'; + +/** + * Catalyst NYC QR sign-up flow: violet arrival screen advances to the dark-violet + * email-first auth screen when the attendee taps "Claim my $500". Logged-in users + * see an email confirmation screen instead. + */ +export function CatalystFlow() { + const { user, isLoading } = useUser(); + const [step, setStep] = useState('arrival'); + + if (isLoading) { + return ( + + +
+ + + ); + } + + if (user?.email) { + return ; + } + + if (step === 'auth') { + return ; + } + + return setStep('auth')} />; +} diff --git a/components/events/catalyst/CatalystLockup.tsx b/components/events/catalyst/CatalystLockup.tsx new file mode 100644 index 000000000..1aea60c7e --- /dev/null +++ b/components/events/catalyst/CatalystLockup.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { Logo } from '@/components/ui/Logo'; +import { CatalystNyc } from './CatalystNyc'; + +export type CatalystLockupVariant = 'arrival' | 'auth' | 'loggedIn'; + +const LOCKUP_SIZES: Record< + CatalystLockupVariant, + { logo: number; nyc: number; gap: number; divHeight: number; catFont: number; catGap: number } +> = { + arrival: { logo: 25, nyc: 15, gap: 13, divHeight: 22, catFont: 21, catGap: 9 }, + auth: { logo: 23, nyc: 13, gap: 11, divHeight: 20, catFont: 18, catGap: 8 }, + loggedIn: { logo: 23, nyc: 13, gap: 11, divHeight: 20, catFont: 18, catGap: 8 }, +}; + +interface CatalystLockupProps { + variant: CatalystLockupVariant; +} + +export function CatalystLockup({ variant }: CatalystLockupProps) { + const sizes = LOCKUP_SIZES[variant]; + + return ( +
+ + + + Catalyst + + + + +
+ ); +} diff --git a/components/events/catalyst/CatalystLoggedInScreen.tsx b/components/events/catalyst/CatalystLoggedInScreen.tsx new file mode 100644 index 000000000..82b0ae488 --- /dev/null +++ b/components/events/catalyst/CatalystLoggedInScreen.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { CATALYST_NYC_EVENT, formatCredit } from './constants'; +import { CatalystLockup } from './CatalystLockup'; +import { CatalystScreenShell } from './CatalystScreenShell'; + +const { footer, loggedIn, contact, creditAmount } = CATALYST_NYC_EVENT; + +interface CatalystLoggedInScreenProps { + email: string; +} + +export function CatalystLoggedInScreen({ email }: CatalystLoggedInScreenProps) { + const router = useRouter(); + + return ( + + + +
+

{loggedIn.title}

+ +

+ You're signed in as {email}. {loggedIn.bodyPrefix} —{' '} + {loggedIn.bodySuffix} {formatCredit(creditAmount)}. +

+ +

+ {loggedIn.mismatchPrefix}{' '} + + {contact.name} ({contact.role}) + {' '} + at{' '} + + {contact.email} + + . +

+ + +
+ +
{footer}
+ + +
+ ); +} diff --git a/components/catalyst/CatalystNyc.tsx b/components/events/catalyst/CatalystNyc.tsx similarity index 78% rename from components/catalyst/CatalystNyc.tsx rename to components/events/catalyst/CatalystNyc.tsx index d21ebb044..21110fac5 100644 --- a/components/catalyst/CatalystNyc.tsx +++ b/components/events/catalyst/CatalystNyc.tsx @@ -1,6 +1,4 @@ -// Dot-matrix "NYC" mark, reproduced exactly from the Catalyst NYC design files -// (5x7-style glyphs as 2.6px rounded cells). Fill is configurable so the mark can -// render white on the violet arrival field and #7C3AED on the white auth screen. +// Dot-matrix "NYC" mark from the Catalyst NYC design files (5x7-style glyphs as 2.6px cells). const NYC_CELLS: ReadonlyArray = [ // N [0.0, 0.0], @@ -53,9 +51,7 @@ const NATIVE_WIDTH = 53.1; const NATIVE_HEIGHT = 21.6; interface CatalystNycProps { - /** Cell fill color. White on the arrival field, #7C3AED on the auth screen. */ fill: string; - /** Rendered height in px; width scales to keep the 53.1:21.6 aspect ratio. */ height?: number; className?: string; } diff --git a/components/events/catalyst/CatalystScreenShell.tsx b/components/events/catalyst/CatalystScreenShell.tsx new file mode 100644 index 000000000..308e661f4 --- /dev/null +++ b/components/events/catalyst/CatalystScreenShell.tsx @@ -0,0 +1,92 @@ +'use client'; + +import type { ReactNode } from 'react'; + +interface CatalystScreenShellProps { + children: ReactNode; + /** space-between layout for arrival; default stacks top-to-bottom */ + contentLayout?: 'spread' | 'stack'; +} + +export function CatalystScreenShell({ + children, + contentLayout = 'stack', +}: CatalystScreenShellProps) { + return ( +
+
+
+
+
+ {children} +
+
+ + +
+ ); +} diff --git a/components/events/catalyst/constants.ts b/components/events/catalyst/constants.ts new file mode 100644 index 000000000..606bde51a --- /dev/null +++ b/components/events/catalyst/constants.ts @@ -0,0 +1,51 @@ +/** Catalyst NYC QR landing — swap this config to restyle for a future event. */ +export const CATALYST_NYC_EVENT = { + route: '/catalyst-nyc', + eventName: 'Catalyst NYC', + footer: 'Catalyst NYC promotional event', + creditAmount: 500, + yieldRate: 0.561, + yieldLabel: '56.1% / yr', + barCount: 11, + maxYears: 10, + animationIntervalMs: 900, + arrival: { + headline: "We want you to fund science — here's some Funding Credits to get started.", + cta: 'Claim my $500', + }, + auth: { + titleLines: ['Sign-up to', 'claim your $500'] as const, + emailLabel: 'Your Catalyst NYC email', + emailPlaceholder: 'name@institution.com', + continueLabel: 'Continue', + loadingLabel: 'Loading...', + googleLabel: 'Continue with Google', + noteHighlight: 'same email you registered with', + }, + loggedIn: { + title: 'Confirm your email', + bodyPrefix: + 'Please double-check that this is the same email you used to register for Catalyst NYC', + bodySuffix: "that's the address we'll use to credit your", + mismatchPrefix: 'If you need to use a different email, contact', + continueLabel: 'Continue to ResearchHub', + }, + contact: { + name: 'Tyler Diorio, PhD', + role: 'Chief of Staff', + email: 'tyler@researchhub.com', + }, + metadata: { + title: 'Catalyst NYC — Join ResearchHub', + description: + 'Catalyst NYC attendees: claim $500 in Funding Credits to fund science. Sign up with the email you registered with.', + }, +} as const; + +export function formatCredit(amount: number): string { + return `$${amount.toLocaleString('en-US')}`; +} + +export function formatMoney(n: number): string { + return `$${Math.round(n).toLocaleString('en-US')}`; +} diff --git a/components/modals/SignupModalContainer.tsx b/components/modals/SignupModalContainer.tsx index da12f791e..bd149114d 100644 --- a/components/modals/SignupModalContainer.tsx +++ b/components/modals/SignupModalContainer.tsx @@ -10,7 +10,7 @@ const EXCLUDED_PATHS = [ '/', // landing page '/referral/join', // referral join page '/referral/join/apply-referral-code', // referral apply referral code page - '/catalyst-nyc', // Catalyst NYC QR landing opens its own branded auth modal + '/catalyst-nyc', // Catalyst NYC QR landing runs its own standalone auth flow // add any other paths where we don't want the modal to show ]; From 8a64841193b6e01825143098a576106edcde4b9e Mon Sep 17 00:00:00 2001 From: Tyler Diorio <109099227+TylerDiorio@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:20:08 -0400 Subject: [PATCH 04/11] update --- app/catalyst-nyc/page.tsx | 5 +- components/Auth/AuthContent.tsx | 27 +- components/Auth/screens/SelectProvider.tsx | 223 ++++++++++- components/Auth/types.ts | 3 + .../events/catalyst/CatalystArrivalBody.tsx | 179 +++++++++ .../events/catalyst/CatalystArrivalScreen.tsx | 156 +------- .../catalyst/CatalystAuthEntryChrome.tsx | 106 ++++++ .../events/catalyst/CatalystAuthModal.tsx | 65 ++++ .../events/catalyst/CatalystAuthScreen.tsx | 358 ++---------------- .../catalyst/CatalystDesktopLoggedIn.tsx | 118 ++++++ .../events/catalyst/CatalystDesktopOffer.tsx | 63 +++ components/events/catalyst/CatalystFlow.tsx | 120 ++++-- components/events/catalyst/CatalystLockup.tsx | 50 ++- .../catalyst/CatalystLoggedInScreen.tsx | 2 +- .../events/catalyst/CatalystScreenShell.tsx | 5 - components/events/catalyst/constants.ts | 2 +- 16 files changed, 915 insertions(+), 567 deletions(-) create mode 100644 components/events/catalyst/CatalystArrivalBody.tsx create mode 100644 components/events/catalyst/CatalystAuthEntryChrome.tsx create mode 100644 components/events/catalyst/CatalystAuthModal.tsx create mode 100644 components/events/catalyst/CatalystDesktopLoggedIn.tsx create mode 100644 components/events/catalyst/CatalystDesktopOffer.tsx diff --git a/app/catalyst-nyc/page.tsx b/app/catalyst-nyc/page.tsx index a77bfe290..65ad00b24 100644 --- a/app/catalyst-nyc/page.tsx +++ b/app/catalyst-nyc/page.tsx @@ -10,9 +10,8 @@ export const metadata: Metadata = { }; /** - * Standalone full-screen mobile flow for the Catalyst NYC QR code. Renders - * outside the app chrome (no PageLayout) so the designed arrival/auth screens - * own the full viewport. + * Catalyst NYC landing: full-screen mobile flow for QR scans; desktop uses + * PageLayout with a standard auth modal on Claim. */ 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..2b8489fa6 --- /dev/null +++ b/components/events/catalyst/CatalystArrivalBody.tsx @@ -0,0 +1,179 @@ +'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); + +interface CatalystArrivalBodyProps { + onClaim: () => void; + /** Compact layout for desktop in-page card */ + compact?: boolean; +} + +export function CatalystArrivalBody({ onClaim, compact = false }: CatalystArrivalBodyProps) { + const [yr, setYr] = useState(0); + const holdRef = useRef(0); + + useEffect(() => { + const prefersReducedMotion = window.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 yrText = + (yr === 0 ? 'Your starting balance today' : `After ${yr} ${yr === 1 ? 'year' : 'years'}`) + + ` · ${yieldLabel}`; + + return ( + <> +
+
{formatMoney(value)}
+
{yrText}
+
+ {Array.from({ length: barCount }).map((_, i) => { + const barValue = creditAmount * (1 + yieldRate * i); + const height = Math.max(3, (barValue / MAX) * 100); + const active = i <= yr; + return ( +
+
+ Now + 5 years + 10 years +
+
+ +

{arrival.headline}

+ +
+ +
{footer}
+
+ + + + ); +} diff --git a/components/events/catalyst/CatalystArrivalScreen.tsx b/components/events/catalyst/CatalystArrivalScreen.tsx index e7a81133c..781058a47 100644 --- a/components/events/catalyst/CatalystArrivalScreen.tsx +++ b/components/events/catalyst/CatalystArrivalScreen.tsx @@ -1,168 +1,18 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { CATALYST_NYC_EVENT, formatMoney } from './constants'; +import { CatalystArrivalBody } from './CatalystArrivalBody'; import { CatalystLockup } from './CatalystLockup'; import { CatalystScreenShell } from './CatalystScreenShell'; -const { - creditAmount, - yieldRate, - yieldLabel, - barCount, - maxYears, - animationIntervalMs, - arrival, - footer, -} = CATALYST_NYC_EVENT; - -const MAX = creditAmount * (1 + yieldRate * maxYears); - interface CatalystArrivalScreenProps { onClaim: () => void; } export function CatalystArrivalScreen({ onClaim }: CatalystArrivalScreenProps) { - const [yr, setYr] = useState(0); - const holdRef = useRef(0); - - useEffect(() => { - const prefersReducedMotion = window.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 yrText = - (yr === 0 ? 'Your starting balance today' : `After ${yr} ${yr === 1 ? 'year' : 'years'}`) + - ` · ${yieldLabel}`; - return ( - - -
-
{formatMoney(value)}
-
{yrText}
-
- {Array.from({ length: barCount }).map((_, i) => { - const barValue = creditAmount * (1 + yieldRate * i); - const height = Math.max(3, (barValue / MAX) * 100); - const active = i <= yr; - return ( -
-
- Now - 5 years - 10 years -
-
- -

{arrival.headline}

- -
- -
{footer}
-
- - + +
); } diff --git a/components/events/catalyst/CatalystAuthEntryChrome.tsx b/components/events/catalyst/CatalystAuthEntryChrome.tsx new file mode 100644 index 000000000..74f877f32 --- /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 }: CatalystAuthEntryTitleProps) { + const onDark = surface === 'dark'; + + return ( + <> +

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

+ + + + ); +} + +interface CatalystAuthEntryNoteProps { + surface: 'dark' | 'light'; +} + +function InfoGlyph({ stroke }: { stroke: string }) { + return ( + + ); +} + +export function CatalystAuthEntryNote({ surface }: CatalystAuthEntryNoteProps) { + 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..fa34b1808 --- /dev/null +++ b/components/events/catalyst/CatalystAuthModal.tsx @@ -0,0 +1,65 @@ +'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 } = CATALYST_NYC_EVENT; + +interface CatalystAuthModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +export function CatalystAuthModal({ isOpen, onClose, onSuccess }: CatalystAuthModalProps) { + if (!isOpen) { + return null; + } + + const handleBackgroundClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+ + +
+ +
+ + } + entryNote={} + /> +
+
+ ); +} diff --git a/components/events/catalyst/CatalystAuthScreen.tsx b/components/events/catalyst/CatalystAuthScreen.tsx index 420af3b22..dc5f69f56 100644 --- a/components/events/catalyst/CatalystAuthScreen.tsx +++ b/components/events/catalyst/CatalystAuthScreen.tsx @@ -2,217 +2,41 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { signIn } from 'next-auth/react'; -import Login from '@/components/Auth/screens/Login'; -import Signup from '@/components/Auth/screens/Signup'; -import VerifyEmail from '@/components/Auth/screens/VerifyEmail'; -import ForgotPassword from '@/components/Auth/screens/ForgotPassword'; -import { AuthService } from '@/services/auth.service'; -import { ApiError } from '@/services/types/api'; -import { isValidEmail } from '@/utils/validation'; -import { useAutoFocus } from '@/hooks/useAutoFocus'; +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'; -type Screen = 'entry' | 'login' | 'signup' | 'verify' | 'forgot'; -type DeeperScreen = Exclude; - const { footer, auth } = CATALYST_NYC_EVENT; -function GoogleGlyph() { - return ( - - ); -} - -function InfoGlyph() { - return ( - - ); -} - export function CatalystAuthScreen() { const router = useRouter(); - const emailInputRef = useAutoFocus(true); - const [screen, setScreen] = useState('entry'); - const [email, setEmail] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [screen, setScreen] = useState('SELECT_PROVIDER'); - const goToFeed = () => router.push('/'); - - const handleContinue = async (e: React.FormEvent) => { - e.preventDefault(); - if (!isValidEmail(email)) { - setError('Please enter a valid email'); - return; - } - setError(null); - setIsLoading(true); - try { - const res = await AuthService.checkAccount(email); - if (res.exists) { - if (res.auth === 'google') { - signIn('google', { callbackUrl: '/' }); - } else if (res.is_verified) { - setScreen('login'); - } else { - setError('Please verify your email before logging in'); - } - } else { - setScreen('signup'); - } - } catch (err) { - setError(err instanceof ApiError ? err.message : 'An error occurred'); - } finally { - setIsLoading(false); - } - }; - - const handleGoogle = () => { - signIn('google', { callbackUrl: '/' }); - }; - - const goEntry = () => { - setError(null); - setScreen('entry'); - }; - - const sharedProps = { email, setEmail, isLoading, error, setError, onClose: goToFeed }; - - const renderDeeperScreen = (deeperScreen: DeeperScreen) => { - switch (deeperScreen) { - case 'login': - return ( - { - setError(null); - setScreen('forgot'); - }} - onSuccess={goToFeed} - modalView - /> - ); - case 'signup': - return ( - setScreen('verify')} - modalView - /> - ); - case 'verify': - return ; - case 'forgot': - return ( - { - setError(null); - setScreen('login'); - }} - modalView - /> - ); - default: { - const _exhaustive: never = deeperScreen; - return _exhaustive; - } - } - }; + const goHome = () => router.push('/'); return ( - +
- {screen === 'entry' ? ( - <> -

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

- -
- - setEmail(e.target.value)} - /> - {error &&

{error}

} - -
- -
- - OR - -
- - - -

- - - Use the {auth.noteHighlight}. - -

- - ) : ( -
{renderDeeperScreen(screen)}
- )} +
+ } + entryNote={} + /> +
{footer}
@@ -224,141 +48,8 @@ export function CatalystAuthScreen() { flex-direction: column; justify-content: center; } - .title { - font-size: 38px; - font-weight: 600; - letter-spacing: -0.03em; - text-align: left; - line-height: 1.05; - margin-bottom: 30px; - } - .label { - display: block; - font-size: 12px; - font-weight: 600; - color: rgba(255, 255, 255, 0.72); - margin-bottom: 8px; - } - .field { - width: 100%; - height: 52px; - background: rgba(255, 255, 255, 0.08); - border: 1.5px solid rgba(255, 255, 255, 0.18); - border-radius: 14px; - padding: 0 16px; - font-family: inherit; - font-size: 15px; - color: #fff; - outline: none; - transition: - border-color 0.15s, - background 0.15s; - } - .field::placeholder { - color: rgba(255, 255, 255, 0.45); - } - .field:focus { - border-color: rgba(255, 255, 255, 0.55); - background: rgba(255, 255, 255, 0.12); - } - .field:focus-visible { - outline: 2px solid rgba(255, 255, 255, 0.75); - outline-offset: 2px; - } - .err { - margin-top: 8px; - font-size: 12.5px; - color: #fca5a5; - } - .btn-primary { + .catalyst-entry { width: 100%; - height: 56px; - margin-top: 14px; - border: none; - border-radius: 14px; - background: #3971ff; - color: #fff; - font-family: inherit; - font-size: 16px; - font-weight: 600; - cursor: pointer; - box-shadow: 0 10px 26px -8px rgba(57, 113, 255, 0.7); - transition: background 0.15s; - } - .btn-primary:hover { - background: #2563eb; - } - .btn-primary:disabled { - opacity: 0.75; - cursor: default; - } - .btn-primary:focus-visible { - outline: 2px solid #fff; - outline-offset: 2px; - } - .divider { - display: flex; - align-items: center; - gap: 12px; - margin: 18px 0; - } - .divider span { - flex: 1; - height: 1px; - background: rgba(255, 255, 255, 0.18); - } - .divider em { - font-style: normal; - font-size: 11px; - font-weight: 500; - letter-spacing: 0.08em; - color: rgba(255, 255, 255, 0.5); - } - .btn-google { - width: 100%; - height: 54px; - border: 1px solid rgba(255, 255, 255, 0.22); - border-radius: 14px; - background: rgba(255, 255, 255, 0.1); - color: #fff; - font-family: inherit; - font-size: 16px; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - -webkit-backdrop-filter: blur(6px); - backdrop-filter: blur(6px); - transition: background 0.15s; - } - .btn-google:hover { - background: rgba(255, 255, 255, 0.16); - } - .btn-google:focus-visible { - outline: 2px solid #fff; - outline-offset: 2px; - } - .note { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 8px; - margin-top: 20px; - text-align: center; - color: rgba(255, 255, 255, 0.82); - font-size: 13px; - } - .note strong { - font-weight: 600; - color: #fff; - } - .foot { - text-align: center; - font-size: 12px; - color: rgba(255, 255, 255, 0.5); } .catalyst-card { width: 100%; @@ -368,6 +59,11 @@ export function CatalystAuthScreen() { padding: 22px 20px; box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.6); } + .foot { + text-align: center; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + } `}
); diff --git a/components/events/catalyst/CatalystDesktopLoggedIn.tsx b/components/events/catalyst/CatalystDesktopLoggedIn.tsx new file mode 100644 index 000000000..78d1f255a --- /dev/null +++ b/components/events/catalyst/CatalystDesktopLoggedIn.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { PageLayout } from '@/app/layouts/PageLayout'; +import { CATALYST_NYC_EVENT, formatCredit } from './constants'; +import { CatalystLockup } from './CatalystLockup'; + +const { footer, loggedIn, contact, creditAmount } = CATALYST_NYC_EVENT; + +interface CatalystDesktopLoggedInProps { + email: string; +} + +export function CatalystDesktopLoggedIn({ email }: CatalystDesktopLoggedInProps) { + const router = useRouter(); + + return ( + +
+
+ + +

{loggedIn.title}

+ +

+ You're signed in as {email}. {loggedIn.bodyPrefix} —{' '} + {loggedIn.bodySuffix} {formatCredit(creditAmount)}. +

+ +

+ {loggedIn.mismatchPrefix}{' '} + + {contact.name} ({contact.role}) + {' '} + at{' '} + + {contact.email} + + . +

+ + + +

{footer}

+
+
+ + +
+ ); +} diff --git a/components/events/catalyst/CatalystDesktopOffer.tsx b/components/events/catalyst/CatalystDesktopOffer.tsx new file mode 100644 index 000000000..e2d8b4074 --- /dev/null +++ b/components/events/catalyst/CatalystDesktopOffer.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { PageLayout } from '@/app/layouts/PageLayout'; +import { CatalystArrivalBody } from './CatalystArrivalBody'; +import { CatalystLockup } from './CatalystLockup'; + +interface CatalystDesktopOfferProps { + onClaim: () => void; +} + +export function CatalystDesktopOffer({ onClaim }: CatalystDesktopOfferProps) { + return ( + +
+
+
+
+
+ + +
+
+
+ + + + ); +} diff --git a/components/events/catalyst/CatalystFlow.tsx b/components/events/catalyst/CatalystFlow.tsx index 2a7ac8bb2..8bbc08e0a 100644 --- a/components/events/catalyst/CatalystFlow.tsx +++ b/components/events/catalyst/CatalystFlow.tsx @@ -1,68 +1,106 @@ 'use client'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { PageLayout } from '@/app/layouts/PageLayout'; import { useUser } from '@/contexts/UserContext'; +import { useIsMobile } from '@/hooks/useIsMobile'; 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'; type Step = 'arrival' | 'auth'; +function MobileLoadingState() { + return ( + + +
+ + + ); +} + +function DesktopLoadingState() { + return ( + +
+
+
+ + ); +} + /** - * Catalyst NYC QR sign-up flow: violet arrival screen advances to the dark-violet - * email-first auth screen when the attendee taps "Claim my $500". Logged-in users - * see an email confirmation screen instead. + * Catalyst NYC QR sign-up flow. Mobile uses full-screen dressed-up screens; desktop + * uses normal page chrome with a standard auth modal on Claim. */ export function CatalystFlow() { + const router = useRouter(); const { user, isLoading } = useUser(); + const isMobile = useIsMobile(); const [step, setStep] = useState('arrival'); + const [authOpen, setAuthOpen] = useState(false); if (isLoading) { - return ( - - -
- - - ); + return isMobile ? : ; } if (user?.email) { - return ; + return isMobile ? ( + + ) : ( + + ); } - if (step === 'auth') { - return ; + if (isMobile) { + if (step === 'auth') { + return ; + } + return setStep('auth')} />; } - return setStep('auth')} />; + return ( + <> + setAuthOpen(true)} /> + setAuthOpen(false)} + onSuccess={() => router.push('/')} + /> + + ); } diff --git a/components/events/catalyst/CatalystLockup.tsx b/components/events/catalyst/CatalystLockup.tsx index 1aea60c7e..7044c74c9 100644 --- a/components/events/catalyst/CatalystLockup.tsx +++ b/components/events/catalyst/CatalystLockup.tsx @@ -3,54 +3,62 @@ import { Logo } from '@/components/ui/Logo'; import { CatalystNyc } from './CatalystNyc'; -export type CatalystLockupVariant = 'arrival' | 'auth' | 'loggedIn'; - -const LOCKUP_SIZES: Record< - CatalystLockupVariant, - { logo: number; nyc: number; gap: number; divHeight: number; catFont: number; catGap: number } -> = { - arrival: { logo: 25, nyc: 15, gap: 13, divHeight: 22, catFont: 21, catGap: 9 }, - auth: { logo: 23, nyc: 13, gap: 11, divHeight: 20, catFont: 18, catGap: 8 }, - loggedIn: { logo: 23, nyc: 13, gap: 11, divHeight: 20, catFont: 18, catGap: 8 }, -}; +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 { - variant: CatalystLockupVariant; + /** White lockup for violet backgrounds; default lockup for light surfaces. */ + theme?: 'onDark' | 'onLight'; } -export function CatalystLockup({ variant }: CatalystLockupProps) { - const sizes = LOCKUP_SIZES[variant]; +export function CatalystLockup({ theme = 'onDark' }: CatalystLockupProps) { + const onDark = theme === 'onDark'; return (
- - + + - Catalyst - + Catalyst +
); diff --git a/components/events/catalyst/CatalystLoggedInScreen.tsx b/components/events/catalyst/CatalystLoggedInScreen.tsx index 82b0ae488..73ecdc594 100644 --- a/components/events/catalyst/CatalystLoggedInScreen.tsx +++ b/components/events/catalyst/CatalystLoggedInScreen.tsx @@ -16,7 +16,7 @@ export function CatalystLoggedInScreen({ email }: CatalystLoggedInScreenProps) { return ( - +

{loggedIn.title}

diff --git a/components/events/catalyst/CatalystScreenShell.tsx b/components/events/catalyst/CatalystScreenShell.tsx index 308e661f4..b6fa50001 100644 --- a/components/events/catalyst/CatalystScreenShell.tsx +++ b/components/events/catalyst/CatalystScreenShell.tsx @@ -46,11 +46,6 @@ export function CatalystScreenShell({ overflow: hidden; color: #fff; } - @media (min-width: 640px) { - .screen { - max-width: 393px; - } - } .screen__bg { position: absolute; inset: 0; diff --git a/components/events/catalyst/constants.ts b/components/events/catalyst/constants.ts index 606bde51a..41d7a5385 100644 --- a/components/events/catalyst/constants.ts +++ b/components/events/catalyst/constants.ts @@ -15,7 +15,7 @@ export const CATALYST_NYC_EVENT = { }, auth: { titleLines: ['Sign-up to', 'claim your $500'] as const, - emailLabel: 'Your Catalyst NYC email', + emailLabel: 'Your email', emailPlaceholder: 'name@institution.com', continueLabel: 'Continue', loadingLabel: 'Loading...', From 6f75e1fc7ffe9a6ab38476a0bdaecc9457071fa6 Mon Sep 17 00:00:00 2001 From: Tyler Diorio <109099227+TylerDiorio@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:21:20 -0400 Subject: [PATCH 05/11] thermos review --- .../catalyst/CatalystDesktopLoggedIn.tsx | 92 +-------- .../events/catalyst/CatalystDesktopOffer.tsx | 26 +-- components/events/catalyst/CatalystFlow.tsx | 4 +- .../events/catalyst/CatalystLoggedInBody.tsx | 181 ++++++++++++++++++ .../catalyst/CatalystLoggedInScreen.tsx | 90 +-------- .../events/catalyst/CatalystScreenShell.tsx | 26 +-- .../catalyst/CatalystVioletBackdrop.tsx | 35 ++++ .../events/catalyst/useCatalystLayout.ts | 25 +++ 8 files changed, 258 insertions(+), 221 deletions(-) create mode 100644 components/events/catalyst/CatalystLoggedInBody.tsx create mode 100644 components/events/catalyst/CatalystVioletBackdrop.tsx create mode 100644 components/events/catalyst/useCatalystLayout.ts diff --git a/components/events/catalyst/CatalystDesktopLoggedIn.tsx b/components/events/catalyst/CatalystDesktopLoggedIn.tsx index 78d1f255a..434c3d8b9 100644 --- a/components/events/catalyst/CatalystDesktopLoggedIn.tsx +++ b/components/events/catalyst/CatalystDesktopLoggedIn.tsx @@ -2,10 +2,8 @@ import { useRouter } from 'next/navigation'; import { PageLayout } from '@/app/layouts/PageLayout'; -import { CATALYST_NYC_EVENT, formatCredit } from './constants'; import { CatalystLockup } from './CatalystLockup'; - -const { footer, loggedIn, contact, creditAmount } = CATALYST_NYC_EVENT; +import { CatalystLoggedInBody } from './CatalystLoggedInBody'; interface CatalystDesktopLoggedInProps { email: string; @@ -19,31 +17,12 @@ export function CatalystDesktopLoggedIn({ email }: CatalystDesktopLoggedInProps)
- -

{loggedIn.title}

- -

- You're signed in as {email}. {loggedIn.bodyPrefix} —{' '} - {loggedIn.bodySuffix} {formatCredit(creditAmount)}. -

- -

- {loggedIn.mismatchPrefix}{' '} - - {contact.name} ({contact.role}) - {' '} - at{' '} - - {contact.email} - - . -

- - - -

{footer}

+ router.push('/')} + showFooter + />
@@ -55,63 +34,6 @@ export function CatalystDesktopLoggedIn({ email }: CatalystDesktopLoggedInProps) padding: 32px; box-shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.15); } - .title { - font-size: 32px; - font-weight: 600; - letter-spacing: -0.03em; - line-height: 1.1; - margin: 24px 0 16px; - color: #0c0720; - } - .body { - font-size: 16px; - line-height: 1.5; - color: #374151; - margin-bottom: 16px; - } - .body strong { - color: #111827; - font-weight: 600; - } - .help { - font-size: 14px; - line-height: 1.5; - color: #6b7280; - margin-bottom: 24px; - } - .help strong { - color: #374151; - font-weight: 600; - } - .contact-link { - color: #3971ff; - text-decoration: underline; - text-underline-offset: 2px; - } - .contact-link:hover { - color: #2563eb; - } - .cta { - width: 100%; - height: 48px; - border: none; - border-radius: 12px; - background: #3971ff; - color: #fff; - font-family: inherit; - font-size: 16px; - font-weight: 600; - cursor: pointer; - } - .cta:hover { - background: #2563eb; - } - .foot { - text-align: center; - margin-top: 20px; - font-size: 12px; - color: #9ca3af; - } `} ); diff --git a/components/events/catalyst/CatalystDesktopOffer.tsx b/components/events/catalyst/CatalystDesktopOffer.tsx index e2d8b4074..655262970 100644 --- a/components/events/catalyst/CatalystDesktopOffer.tsx +++ b/components/events/catalyst/CatalystDesktopOffer.tsx @@ -3,6 +3,7 @@ import { PageLayout } from '@/app/layouts/PageLayout'; import { CatalystArrivalBody } from './CatalystArrivalBody'; import { CatalystLockup } from './CatalystLockup'; +import { CatalystVioletBackdrop } from './CatalystVioletBackdrop'; interface CatalystDesktopOfferProps { onClaim: () => void; @@ -13,8 +14,7 @@ export function CatalystDesktopOffer({ onClaim }: CatalystDesktopOfferProps) {
-
-
+
@@ -30,28 +30,6 @@ export function CatalystDesktopOffer({ onClaim }: CatalystDesktopOfferProps) { background: #0c0720; color: #fff; } - .offer-card__bg { - position: absolute; - inset: 0; - background: radial-gradient( - 108% 86% at 24% 22%, - #7b43be 0%, - #5a2db0 24%, - #3a1f86 46%, - #20104e 72%, - #0c0720 100% - ); - } - .offer-card__grid { - position: absolute; - inset: 0; - background-image: - linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px); - background-size: 22px 22px; - -webkit-mask-image: radial-gradient(120% 100% at 30% 20%, transparent 35%, #000 100%); - mask-image: radial-gradient(120% 100% at 30% 20%, transparent 35%, #000 100%); - } .offer-card__content { position: relative; z-index: 1; diff --git a/components/events/catalyst/CatalystFlow.tsx b/components/events/catalyst/CatalystFlow.tsx index 8bbc08e0a..5426d4b42 100644 --- a/components/events/catalyst/CatalystFlow.tsx +++ b/components/events/catalyst/CatalystFlow.tsx @@ -4,7 +4,6 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { PageLayout } from '@/app/layouts/PageLayout'; import { useUser } from '@/contexts/UserContext'; -import { useIsMobile } from '@/hooks/useIsMobile'; import { CatalystArrivalScreen } from './CatalystArrivalScreen'; import { CatalystAuthModal } from './CatalystAuthModal'; import { CatalystAuthScreen } from './CatalystAuthScreen'; @@ -13,6 +12,7 @@ import { CatalystDesktopOffer } from './CatalystDesktopOffer'; import { CatalystLoggedInScreen } from './CatalystLoggedInScreen'; import { CatalystLockup } from './CatalystLockup'; import { CatalystScreenShell } from './CatalystScreenShell'; +import { useCatalystLayout } from './useCatalystLayout'; type Step = 'arrival' | 'auth'; @@ -70,7 +70,7 @@ function DesktopLoadingState() { export function CatalystFlow() { const router = useRouter(); const { user, isLoading } = useUser(); - const isMobile = useIsMobile(); + const isMobile = useCatalystLayout(); const [step, setStep] = useState('arrival'); const [authOpen, setAuthOpen] = useState(false); diff --git a/components/events/catalyst/CatalystLoggedInBody.tsx b/components/events/catalyst/CatalystLoggedInBody.tsx new file mode 100644 index 000000000..d8ad28d9c --- /dev/null +++ b/components/events/catalyst/CatalystLoggedInBody.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { CATALYST_NYC_EVENT, formatCredit } from './constants'; + +const { footer, loggedIn, contact, creditAmount } = CATALYST_NYC_EVENT; + +interface CatalystLoggedInBodyProps { + email: string; + variant: 'mobile' | 'desktop'; + onContinue: () => void; + showFooter?: boolean; +} + +export function CatalystLoggedInBody({ + email, + variant, + onContinue, + showFooter = false, +}: CatalystLoggedInBodyProps) { + const isMobile = variant === 'mobile'; + + return ( + <> +

{loggedIn.title}

+ +

+ You're signed in as {email}. {loggedIn.bodyPrefix} —{' '} + {loggedIn.bodySuffix} {formatCredit(creditAmount)}. +

+ +

+ {loggedIn.mismatchPrefix}{' '} + + {contact.name} ({contact.role}) + {' '} + at{' '} + + {contact.email} + + . +

+ + + + {showFooter && ( +

{footer}

+ )} + + + + ); +} diff --git a/components/events/catalyst/CatalystLoggedInScreen.tsx b/components/events/catalyst/CatalystLoggedInScreen.tsx index 73ecdc594..947225726 100644 --- a/components/events/catalyst/CatalystLoggedInScreen.tsx +++ b/components/events/catalyst/CatalystLoggedInScreen.tsx @@ -1,11 +1,12 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { CATALYST_NYC_EVENT, formatCredit } from './constants'; +import { CATALYST_NYC_EVENT } from './constants'; import { CatalystLockup } from './CatalystLockup'; +import { CatalystLoggedInBody } from './CatalystLoggedInBody'; import { CatalystScreenShell } from './CatalystScreenShell'; -const { footer, loggedIn, contact, creditAmount } = CATALYST_NYC_EVENT; +const { footer } = CATALYST_NYC_EVENT; interface CatalystLoggedInScreenProps { email: string; @@ -19,28 +20,7 @@ export function CatalystLoggedInScreen({ email }: CatalystLoggedInScreenProps) {
-

{loggedIn.title}

- -

- You're signed in as {email}. {loggedIn.bodyPrefix} —{' '} - {loggedIn.bodySuffix} {formatCredit(creditAmount)}. -

- -

- {loggedIn.mismatchPrefix}{' '} - - {contact.name} ({contact.role}) - {' '} - at{' '} - - {contact.email} - - . -

- - + router.push('/')} />
{footer}
@@ -52,68 +32,6 @@ export function CatalystLoggedInScreen({ email }: CatalystLoggedInScreenProps) { flex-direction: column; justify-content: center; } - .title { - font-size: 38px; - font-weight: 600; - letter-spacing: -0.03em; - text-align: left; - line-height: 1.05; - margin-bottom: 24px; - } - .body { - font-size: 16px; - line-height: 1.5; - color: rgba(255, 255, 255, 0.88); - margin-bottom: 20px; - } - .body strong { - color: #fff; - font-weight: 600; - } - .help { - font-size: 14px; - line-height: 1.5; - color: rgba(255, 255, 255, 0.72); - margin-bottom: 28px; - } - .help strong { - color: rgba(255, 255, 255, 0.9); - font-weight: 600; - } - .contact-link { - color: #c8b6f2; - text-decoration: underline; - text-underline-offset: 2px; - } - .contact-link:hover { - color: #fff; - } - .contact-link:focus-visible { - outline: 2px solid #c8b6f2; - outline-offset: 2px; - border-radius: 2px; - } - .cta { - width: 100%; - height: 56px; - border: none; - border-radius: 14px; - background: #3971ff; - color: #fff; - font-family: inherit; - font-size: 16px; - font-weight: 600; - cursor: pointer; - box-shadow: 0 10px 26px -8px rgba(57, 113, 255, 0.7); - transition: background 0.15s; - } - .cta:hover { - background: #2563eb; - } - .cta:focus-visible { - outline: 2px solid #fff; - outline-offset: 2px; - } .foot { text-align: center; font-size: 12px; diff --git a/components/events/catalyst/CatalystScreenShell.tsx b/components/events/catalyst/CatalystScreenShell.tsx index b6fa50001..f5408d381 100644 --- a/components/events/catalyst/CatalystScreenShell.tsx +++ b/components/events/catalyst/CatalystScreenShell.tsx @@ -1,6 +1,7 @@ 'use client'; import type { ReactNode } from 'react'; +import { CatalystVioletBackdrop } from './CatalystVioletBackdrop'; interface CatalystScreenShellProps { children: ReactNode; @@ -15,8 +16,7 @@ export function CatalystScreenShell({ return (
-
-
+
{children}
@@ -46,28 +46,6 @@ export function CatalystScreenShell({ overflow: hidden; color: #fff; } - .screen__bg { - position: absolute; - inset: 0; - background: radial-gradient( - 108% 86% at 24% 22%, - #7b43be 0%, - #5a2db0 24%, - #3a1f86 46%, - #20104e 72%, - #0c0720 100% - ); - } - .screen__grid { - position: absolute; - inset: 0; - background-image: - linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px); - background-size: 22px 22px; - -webkit-mask-image: radial-gradient(120% 100% at 30% 20%, transparent 35%, #000 100%); - mask-image: radial-gradient(120% 100% at 30% 20%, transparent 35%, #000 100%); - } .content { position: relative; z-index: 2; 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 ( + <> +