diff --git a/packages/shared/src/components/InteractionCounter.tsx b/packages/shared/src/components/InteractionCounter.tsx index 84831e54f0..274ab270d8 100644 --- a/packages/shared/src/components/InteractionCounter.tsx +++ b/packages/shared/src/components/InteractionCounter.tsx @@ -32,7 +32,7 @@ export default function InteractionCounter({ }, [value]); const elementClassName = classNames( - 'flex h-5 min-w-[1ch] flex-col overflow-hidden', + 'flex h-5 min-w-[2ch] flex-col items-start overflow-hidden text-left !leading-5', className, ); @@ -49,27 +49,21 @@ export default function InteractionCounter({ setShownValue(value); }; - const childClassName = - 'h-5 inline-block transition-[opacity,transform] ease-in-out duration-300 will-change-[opacity,transform]'; + const animationContainerClassName = + 'flex flex-col items-start transition-transform duration-300 ease-in-out will-change-transform'; + const rowClassName = 'inline-block h-5 shrink-0 !leading-5'; return ( - {largeNumberFormat(shownValue)} - - - {largeNumberFormat(value)} + {largeNumberFormat(shownValue)} + {largeNumberFormat(value)} ); diff --git a/packages/shared/src/components/LoginButton.tsx b/packages/shared/src/components/LoginButton.tsx index 4d29121e4a..01e6d082a2 100644 --- a/packages/shared/src/components/LoginButton.tsx +++ b/packages/shared/src/components/LoginButton.tsx @@ -15,6 +15,7 @@ interface ClassName { interface LoginButtonProps { className?: ClassName; + onSignupClick?: () => boolean | void; } enum ButtonCopy { @@ -32,11 +33,17 @@ const getLogEvent = (copy: ButtonCopy): LogEvent => ({ export default function LoginButton({ className = {}, + onSignupClick, }: LoginButtonProps): ReactElement { const { logEvent } = useLogContext(); const { showLogin } = useAuthContext(); const onClick = (copy: ButtonCopy) => { logEvent(getLogEvent(copy)); + + if (copy === ButtonCopy.Signup && onSignupClick?.()) { + return; + } + showLogin({ trigger: AuthTriggers.MainButton, options: { diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 20442a4e97..09e3f30cee 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -19,7 +19,7 @@ import { PromptElement } from './modals/Prompt'; import { useNotificationParams } from '../hooks/useNotificationParams'; import { useAuthContext } from '../contexts/AuthContext'; import { SharedFeedPage } from './utilities'; -import { isTesting, onboardingUrl } from '../lib/constants'; +import { isTesting, onboardingUrl, onboardingV2Path } from '../lib/constants'; import { useBanner } from '../hooks/useBanner'; import { useGrowthBookContext } from './GrowthBookProvider'; import { @@ -58,6 +58,7 @@ export interface MainLayoutProps screenCentered?: boolean; customBanner?: ReactNode; showSidebar?: boolean; + sidebarDisabled?: boolean; onNavTabClick?: (tab: string) => void; canGoBack?: string; hideBackButton?: boolean; @@ -71,8 +72,10 @@ function MainLayoutComponent({ isNavItemsButton, customBanner, additionalButtons, + hideSearchField, screenCentered = true, showSidebar = true, + sidebarDisabled = true, className, onLogoClick, onNavTabClick, @@ -97,6 +100,26 @@ function MainLayoutComponent({ useAuthVerificationRecovery(); useNotificationParams(); + const onSignupClick = (): boolean => { + if (router.pathname !== onboardingV2Path) { + return false; + } + + router.replace( + { + pathname: router.pathname, + query: { + ...router.query, + onbSignup: '1', + }, + }, + undefined, + { shallow: true }, + ); + + return true; + }; + useEffect(() => { if (!isNotificationsReady || unreadCount === 0 || hasLoggedImpression) { return; @@ -192,7 +215,9 @@ function MainLayoutComponent({ hasBanner={isBannerAvailable} sidebarRendered={sidebarRendered} additionalButtons={additionalButtons} + hideSearchField={hideSearchField} onLogoClick={onLogoClick} + onSignupClick={onSignupClick} />
)} {children} diff --git a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx index f0d1192b5b..26c2d1f97b 100644 --- a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx +++ b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx @@ -155,7 +155,7 @@ export const OnboardingRegistrationForm = ({ onLogin={() => onExistingEmail?.('')} className={{ container: - 'mx-auto mt-6 text-center text-text-secondary typo-callout', + 'mx-auto mt-6 w-full justify-center border-t border-border-subtlest-tertiary pt-6 text-center text-text-secondary typo-callout', login: '!text-inherit', }} /> diff --git a/packages/shared/src/components/auth/RegistrationForm.tsx b/packages/shared/src/components/auth/RegistrationForm.tsx index 74260cc0eb..59aa4010f4 100644 --- a/packages/shared/src/components/auth/RegistrationForm.tsx +++ b/packages/shared/src/components/auth/RegistrationForm.tsx @@ -33,7 +33,7 @@ import { TypographyTag, TypographyType, } from '../typography/Typography'; -import { onboardingGradientClasses } from '../onboarding/common'; + import { useAuthData } from '../../contexts/AuthDataContext'; import { authAtom } from '../../features/onboarding/store/onboarding.store'; import { FunnelTargetId } from '../../features/onboarding/types/funnelEvents'; @@ -81,6 +81,8 @@ const RegistrationForm = ({ const [isSubmitted, setIsSubmitted] = useState(false); const [name, setName] = useState(''); const isRecruiterOnboarding = trigger === AuthTriggers.RecruiterSelfServe; + const hideExperienceLevel = + isRecruiterOnboarding || trigger === AuthTriggers.Onboarding; const { username, setUsername, @@ -162,7 +164,7 @@ const RegistrationForm = ({ ); delete values?.['cf-turnstile-response']; - const requiresExperienceLevel = !isRecruiterOnboarding; + const requiresExperienceLevel = !hideExperienceLevel; if ( !values['traits.name']?.length || !values['traits.username']?.length || @@ -271,9 +273,10 @@ const RegistrationForm = ({ variant={ButtonVariant.Secondary} /> Join daily.dev @@ -385,7 +388,7 @@ const RegistrationForm = ({ } rightIcon={usernameIcon} /> - {!isRecruiterOnboarding && ( + {!hideExperienceLevel && ( { const { logEvent } = useLogContext(); const { user } = useContext(AuthContext); + const hideExperienceLevel = trigger === AuthTriggers.Onboarding; const [nameHint, setNameHint] = useState(null); const [usernameHint, setUsernameHint] = useState(null); const [experienceLevelHint, setExperienceLevelHint] = useState(null); @@ -118,7 +120,7 @@ export const SocialRegistrationForm = ({ return; } - if (!values.experienceLevel?.length) { + if (!hideExperienceLevel && !values.experienceLevel?.length) { logError('Experience level not provided'); setExperienceLevelHint('Please select your experience level'); return; @@ -229,18 +231,20 @@ export const SocialRegistrationForm = ({ } rightIcon={isLoadingUsername ? : null} /> - { - if (experienceLevelHint) { - setExperienceLevelHint(null); - } - }} - valid={experienceLevelHint === null} - hint={experienceLevelHint} - saveHintSpace - /> + {!hideExperienceLevel && ( + { + if (experienceLevelHint) { + setExperienceLevelHint(null); + } + }} + valid={experienceLevelHint === null} + hint={experienceLevelHint} + saveHintSpace + /> + )} Your email will be used to send you product and community updates diff --git a/packages/shared/src/components/layout/HeaderButtons.tsx b/packages/shared/src/components/layout/HeaderButtons.tsx index 2dc74da71b..a2bcf2201b 100644 --- a/packages/shared/src/components/layout/HeaderButtons.tsx +++ b/packages/shared/src/components/layout/HeaderButtons.tsx @@ -10,12 +10,14 @@ import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; interface HeaderButtonsProps { additionalButtons?: ReactNode; + onSignupClick?: () => boolean | void; } const Container = classed('div', 'ml-auto flex justify-end gap-3'); export function HeaderButtons({ additionalButtons, + onSignupClick, }: HeaderButtonsProps): ReactElement { const { isLoggedIn, isAuthReady } = useAuthContext(); const { loadedSettings } = useSettingsContext(); @@ -28,6 +30,7 @@ export function HeaderButtons({ return ( unknown; + hideSearchField?: boolean; + onSignupClick?: () => boolean | void; } const SearchPanel = dynamic( @@ -39,6 +41,8 @@ function MainLayoutHeader({ sidebarRendered, additionalButtons, onLogoClick, + hideSearchField, + onSignupClick, }: MainLayoutHeaderProps): ReactElement { const { loadedSettings } = useSettingsContext(); const { streak, isStreaksEnabled } = useReadingStreak(); @@ -56,6 +60,7 @@ function MainLayoutHeader({ const RenderSearchPanel = useCallback( () => + !hideSearchField && loadedSettings && ( ), - [loadedSettings, isSearchPage, hasBanner], + [loadedSettings, isSearchPage, hasBanner, hideSearchField], ); if (loadedSettings && !isLaptop) { @@ -114,7 +119,10 @@ function MainLayoutHeader({ /> - + )} diff --git a/packages/shared/src/components/sidebar/Sidebar.tsx b/packages/shared/src/components/sidebar/Sidebar.tsx index 7433b7d087..a5abaa4fc8 100644 --- a/packages/shared/src/components/sidebar/Sidebar.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.tsx @@ -21,6 +21,7 @@ interface SidebarProps { isNavButtons?: boolean; onNavTabClick?: (tab: string) => void; onLogoClick?: (e: React.MouseEvent) => unknown; + disabled?: boolean; } export const Sidebar = ({ @@ -28,6 +29,7 @@ export const Sidebar = ({ onNavTabClick, onLogoClick, activePage, + disabled, }: SidebarProps): ReactElement => { const isLaptop = useViewSize(ViewSize.Laptop); const isTablet = useViewSize(ViewSize.Tablet); @@ -39,6 +41,7 @@ export const Sidebar = ({ activePage={activePage} onLogoClick={onLogoClick} featureTheme={featureTheme} + disabled={disabled} /> ); } @@ -52,6 +55,7 @@ export const Sidebar = ({ featureTheme={featureTheme} isNavButtons={isNavButtons} onNavTabClick={onNavTabClick} + disabled={disabled} /> ); } diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index ec062985c5..e1caae2943 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -22,58 +22,70 @@ type SidebarDesktopProps = { }; isNavButtons?: boolean; onNavTabClick?: (tab: string) => void; + disabled?: boolean; }; export const SidebarDesktop = ({ activePage: activePageProp, featureTheme, isNavButtons, onNavTabClick, + disabled, }: SidebarDesktopProps): ReactElement => { const router = useRouter(); const { sidebarExpanded } = useSettingsContext(); const { isAvailable: isBannerAvailable } = useBanner(); const activePage = activePageProp || router.asPath || router.pathname; + const effectiveExpanded = disabled ? false : sidebarExpanded; + const defaultRenderSectionProps = useMemo( () => ({ - sidebarExpanded, - shouldShowLabel: sidebarExpanded, + sidebarExpanded: effectiveExpanded, + shouldShowLabel: effectiveExpanded, activePage, }), - [sidebarExpanded, activePage], + [effectiveExpanded, activePage], ); return ( - diff --git a/packages/shared/src/components/sidebar/SidebarTablet.tsx b/packages/shared/src/components/sidebar/SidebarTablet.tsx index 9df51969c9..90b0a0e551 100644 --- a/packages/shared/src/components/sidebar/SidebarTablet.tsx +++ b/packages/shared/src/components/sidebar/SidebarTablet.tsx @@ -46,6 +46,7 @@ export const SidebarTablet = ({ activePage, featureTheme, onLogoClick, + disabled, }: { activePage: string; featureTheme?: { @@ -53,6 +54,7 @@ export const SidebarTablet = ({ logoText?: string; }; onLogoClick?: (e: React.MouseEvent) => unknown; + disabled?: boolean; }): ReactElement => { const { alerts } = useAlertsContext(); const { user, isLoggedIn, squads } = useAuthContext(); @@ -91,6 +93,8 @@ export const SidebarTablet = ({ className={classNames( 'w-16 items-center gap-4', featureTheme && 'bg-transparent', + disabled && + 'pointer-events-none select-none [&_a]:!text-text-disabled [&_button]:!text-text-disabled [&_span]:!text-text-disabled [&_svg]:!text-text-disabled', )} > export const webappUrl = process.env.NEXT_PUBLIC_WEBAPP_URL as string; export const onboardingUrl = `${webappUrl}onboarding`; +export const onboardingV2Path = '/onboarding-v2'; export const plusUrl = `${webappUrl}plus`; export const managePlusUrl = 'https://r.daily.dev/billing'; export const plusDetailsUrl = 'https://r.daily.dev/plus-onboarding'; diff --git a/packages/webapp/components/footer/FooterWrapper.tsx b/packages/webapp/components/footer/FooterWrapper.tsx index d93875a744..47fcce85a3 100644 --- a/packages/webapp/components/footer/FooterWrapper.tsx +++ b/packages/webapp/components/footer/FooterWrapper.tsx @@ -6,6 +6,7 @@ import { PostType } from '@dailydotdev/shared/src/graphql/posts'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import ScrollToTopButton from '@dailydotdev/shared/src/components/ScrollToTopButton'; +import { onboardingV2Path } from '@dailydotdev/shared/src/lib/constants'; import { blurClasses } from './common'; const NewComment = dynamic(() => @@ -42,6 +43,7 @@ export default function FooterWrapper({ post, }: FooterNavBarProps): ReactElement { const router = useRouter(); + const isOnboardingV2 = router?.pathname === onboardingV2Path; const showPlusButton = !router?.pathname?.startsWith('/settings') && @@ -55,9 +57,11 @@ export default function FooterWrapper({ 'bg-gradient-to-t from-background-subtle from-70% to-transparent px-2 pt-2', )} > -
- -
+ {!isOnboardingV2 && ( +
+ +
+ )} {post && post.type !== PostType.Brief && (
- {children} - {showNav &&
} +
{children}
); diff --git a/packages/webapp/pages/onboarding-v2.tsx b/packages/webapp/pages/onboarding-v2.tsx new file mode 100644 index 0000000000..492a900b22 --- /dev/null +++ b/packages/webapp/pages/onboarding-v2.tsx @@ -0,0 +1,4653 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import type { NextSeoProps } from 'next-seo'; +import MainFeedLayout from '@dailydotdev/shared/src/components/MainFeedLayout'; +import MainLayout from '@dailydotdev/shared/src/components/MainLayout'; +import type { MainLayoutProps } from '@dailydotdev/shared/src/components/MainLayout'; +import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext'; +import { ActiveFeedNameContext } from '@dailydotdev/shared/src/contexts/ActiveFeedNameContext'; +import { SharedFeedPage } from '@dailydotdev/shared/src/components/utilities/common'; +import { + ThemeMode, + useSettingsContext, +} from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + downloadBrowserExtension, + mobileAppUrl, +} from '@dailydotdev/shared/src/lib/constants'; +import { UserExperienceLevel } from '@dailydotdev/shared/src/lib/user'; +import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; +import { + BrowserName, + getCurrentBrowserName, +} from '@dailydotdev/shared/src/lib/func'; +import { ChromeIcon } from '@dailydotdev/shared/src/components/icons/Browser/Chrome'; +import { MagicIcon } from '@dailydotdev/shared/src/components/icons/Magic'; +import { NewTabIcon } from '@dailydotdev/shared/src/components/icons/NewTab'; +import { TerminalIcon } from '@dailydotdev/shared/src/components/icons/Terminal'; +import { VIcon } from '@dailydotdev/shared/src/components/icons/V'; +import { StarIcon } from '@dailydotdev/shared/src/components/icons/Star'; +import { cloudinaryOnboardingExtension } from '@dailydotdev/shared/src/lib/image'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import Logo, { LogoPosition } from '@dailydotdev/shared/src/components/Logo'; +import { FooterLinks } from '@dailydotdev/shared/src/components/footer/FooterLinks'; +import AuthOptions from '@dailydotdev/shared/src/components/auth/AuthOptions'; +import type { AuthProps } from '@dailydotdev/shared/src/components/auth/common'; +import { AuthDisplay } from '@dailydotdev/shared/src/components/auth/common'; +import { useRouter } from 'next/router'; +import { getLayout as getFooterNavBarLayout } from '../components/layouts/FooterNavBarLayout'; +import { getTemplatedTitle } from '../components/layouts/utils'; +import { defaultOpenGraph, defaultSeo } from '../next-seo'; + +const seo: NextSeoProps = { + title: getTemplatedTitle('Onboarding V2'), + openGraph: { ...defaultOpenGraph }, + ...defaultSeo, + noindex: true, + nofollow: true, +}; + +type RisingTag = { + label: string; + left: string; + delay: string; + duration: string; + driftX: number; +}; + +const RISING_TAGS_DESKTOP: RisingTag[] = [ + { label: 'React', left: '8%', delay: '0s', duration: '14s', driftX: 12 }, + { label: 'AI & ML', left: '28%', delay: '1.2s', duration: '15s', driftX: -8 }, + { + label: 'System Design', + left: '52%', + delay: '0.6s', + duration: '14.5s', + driftX: 10, + }, + { label: 'Docker', left: '78%', delay: '2s', duration: '13.8s', driftX: -14 }, + { + label: 'TypeScript', + left: '18%', + delay: '3.4s', + duration: '15.2s', + driftX: 8, + }, + { + label: 'Next.js', + left: '88%', + delay: '2.8s', + duration: '14.4s', + driftX: -10, + }, + { + label: 'Python', + left: '42%', + delay: '4.2s', + duration: '14.8s', + driftX: -6, + }, + { + label: 'Kubernetes', + left: '66%', + delay: '5s', + duration: '14.2s', + driftX: 12, + }, +]; + +const RISING_TAGS_MOBILE: RisingTag[] = [ + { label: 'React', left: '10%', delay: '0s', duration: '13.5s', driftX: 8 }, + { + label: 'AI & ML', + left: '55%', + delay: '1.5s', + duration: '14s', + driftX: -10, + }, + { label: 'Docker', left: '30%', delay: '3s', duration: '13s', driftX: 6 }, + { + label: 'TypeScript', + left: '75%', + delay: '4.5s', + duration: '14.5s', + driftX: -8, + }, +]; + +const SELECTABLE_TOPICS = [ + { label: 'React', color: 'water' as const }, + { label: 'TypeScript', color: 'water' as const }, + { label: 'Python', color: 'onion' as const }, + { label: 'Node.js', color: 'avocado' as const }, + { label: 'Next.js', color: 'water' as const }, + { label: 'AI & ML', color: 'cheese' as const }, + { label: 'System Design', color: 'cabbage' as const }, + { label: 'Docker', color: 'onion' as const }, + { label: 'AWS', color: 'cheese' as const }, + { label: 'GraphQL', color: 'cabbage' as const }, + { label: 'Rust', color: 'ketchup' as const }, + { label: 'Go', color: 'water' as const }, + { label: 'DevOps', color: 'onion' as const }, + { label: 'Kubernetes', color: 'onion' as const }, + { label: 'PostgreSQL', color: 'water' as const }, + { label: 'Security', color: 'ketchup' as const }, + { label: 'Testing', color: 'avocado' as const }, + { label: 'CSS', color: 'bacon' as const }, + { label: 'Open Source', color: 'cabbage' as const }, + { label: 'API Design', color: 'cabbage' as const }, +]; + +const TOPIC_SELECTED_STYLES = + 'border-white/[0.12] bg-white/[0.10] text-text-primary'; + +type GithubImportPhase = + | 'idle' + | 'running' + | 'awaitingSeniority' + | 'confirmingSeniority' + | 'finishing' + | 'complete'; +type ImportFlowSource = 'github' | 'ai'; +type GithubImportBodyPhase = 'checklist' | 'seniority' | 'default'; + +const AI_IMPORT_STEPS = [ + { label: 'Analyzing your profile', threshold: 12 }, + { label: 'Matching interests', threshold: 30 }, + { label: 'Mapping your stack', threshold: 46 }, + { label: 'Inferring seniority', threshold: 68 }, + { label: 'Building your feed', threshold: 95 }, +]; + +const EXPERIENCE_LEVEL_OPTIONS = Object.entries(UserExperienceLevel).map( + ([value, label]) => ({ + value: value as keyof typeof UserExperienceLevel, + label, + }), +); + +const getExperienceLevelOptionParts = ( + label: string, +): { title: string; meta: string | null } => { + const match = label.match(/^(.*?)(?:\s*\(([^)]+)\))?$/); + if (!match) { + return { title: label, meta: null }; + } + + return { + title: match[1].trim(), + meta: match[2]?.trim() ?? null, + }; +}; + +const GITHUB_IMPORT_STEPS = [ + { label: 'Connecting account', threshold: 12 }, + { label: 'Scanning repositories', threshold: 30 }, + { label: 'Matching interests', threshold: 46 }, + { label: 'Inferring seniority', threshold: 68 }, + { label: 'Building your feed', threshold: 96 }, +]; + +const CONFETTI_COLORS = [ + 'bg-accent-cabbage-default', + 'bg-accent-onion-default', + 'bg-accent-cheese-default', + 'bg-accent-water-default', + 'bg-accent-avocado-default', + 'bg-accent-bacon-default', +]; + +type ConfettiParticle = { + id: string; + left: string; + delay: string; + color: string; + size: 'sm' | 'md' | 'lg' | 'xl'; + shape: 'rect' | 'circle' | 'star'; + drift: number; + speed: number; +}; + +function buildConfettiParticles(): ConfettiParticle[] { + const particles: ConfettiParticle[] = []; + const SIZES = ['sm', 'md', 'lg', 'xl'] as const; + const SHAPES = ['rect', 'circle', 'star'] as const; + for (let i = 0; i < 24; i += 1) { + const col = CONFETTI_COLORS[i % CONFETTI_COLORS.length]; + const opacity = 65 + Math.round(Math.random() * 30); + particles.push({ + id: `cf-${i}`, + left: `${1 + Math.random() * 98}%`, + delay: `${Math.round(Math.random() * 2400)}ms`, + color: `${col}/${opacity}`, + size: SIZES[Math.floor(Math.random() * SIZES.length)], + shape: SHAPES[Math.floor(Math.random() * SHAPES.length)], + drift: -40 + Math.random() * 80, + speed: 3.5 + Math.random() * 2.5, + }); + } + return particles; +} + +const OnboardingV2Page = (): ReactElement => { + const router = useRouter(); + const { showLogin } = useAuthContext(); + const { applyThemeMode } = useSettingsContext(); + const [showSignupPrompt, setShowSignupPrompt] = useState(false); + const [mounted, setMounted] = useState(false); + const [tagsReady, setTagsReady] = useState(false); + const [feedVisible, setFeedVisible] = useState(false); + const [panelVisible, setPanelVisible] = useState(false); + const [panelStageProgress, setPanelStageProgress] = useState(0); + const [selectedTopics, setSelectedTopics] = useState>(new Set()); + const [aiPrompt, setAiPrompt] = useState(''); + const [feedReadyState, setFeedReadyState] = useState(false); + const [showExtensionPromo, setShowExtensionPromo] = useState(false); + const [showAuthSignup, setShowAuthSignup] = useState(false); + const [authDisplay, setAuthDisplay] = useState(AuthDisplay.OnboardingSignup); + const [showSignupChooser, setShowSignupChooser] = useState(false); + const [showGithubImportFlow, setShowGithubImportFlow] = useState(false); + const [importFlowSource, setImportFlowSource] = + useState('github'); + const [githubImportPhase, setGithubImportPhase] = + useState('idle'); + const [githubImportProgress, setGithubImportProgress] = useState(0); + const [selectedExperienceLevel, setSelectedExperienceLevel] = useState< + keyof typeof UserExperienceLevel | null + >(null); + const [githubImportBodyHeight, setGithubImportBodyHeight] = useState< + number | null + >(null); + const [githubImportExiting, setGithubImportExiting] = useState(false); + const [signupContext, setSignupContext] = useState< + 'topics' | 'github' | 'ai' | 'manual' | null + >(null); + const prevBodyOverflowRef = useRef(''); + const pageRef = useRef(null); + const panelSentinelRef = useRef(null); + const panelStageRef = useRef(null); + const heroRef = useRef(null); + const panelBoxRef = useRef(null); + const scrollY = useRef(0); + const githubImportTimerRef = useRef(null); + const githubResumeTimeoutRef = useRef(null); + const githubImportBodyContentRef = useRef(null); + const authFormRef = useRef(null); + const importFlowSourceRef = useRef('github'); + + const popularFeedNameValue = useMemo( + () => ({ feedName: SharedFeedPage.Popular as const }), + [], + ); + + const toggleTopic = useCallback((topic: string) => { + setSelectedTopics((prev) => { + const next = new Set(prev); + if (next.has(topic)) { + next.delete(topic); + } else { + next.add(topic); + } + return next; + }); + }, []); + + const openSignup = useCallback( + (context: 'topics' | 'github' | 'ai' | 'manual') => { + setSignupContext(context); + setShowSignupPrompt(true); + }, + [], + ); + + const clearGithubImportTimer = useCallback(() => { + if (githubImportTimerRef.current === null) { + return; + } + window.clearInterval(githubImportTimerRef.current); + githubImportTimerRef.current = null; + }, []); + + const clearGithubResumeTimeout = useCallback(() => { + if (githubResumeTimeoutRef.current === null) { + return; + } + window.clearTimeout(githubResumeTimeoutRef.current); + githubResumeTimeoutRef.current = null; + }, []); + + const startImportFlow = useCallback( + (source: ImportFlowSource) => { + clearGithubImportTimer(); + clearGithubResumeTimeout(); + importFlowSourceRef.current = source; + setImportFlowSource(source); + setSelectedExperienceLevel(null); + setGithubImportProgress(10); + setGithubImportPhase('running'); + setShowGithubImportFlow(true); + }, + [clearGithubImportTimer, clearGithubResumeTimeout, setImportFlowSource], + ); + + const startGithubImportFlow = useCallback(() => { + startImportFlow('github'); + }, [startImportFlow]); + + const closeGithubImportFlow = useCallback(() => { + clearGithubImportTimer(); + clearGithubResumeTimeout(); + setShowGithubImportFlow(false); + setGithubImportExiting(false); + setSelectedExperienceLevel(null); + setGithubImportProgress(0); + setGithubImportPhase('idle'); + setImportFlowSource('github'); + }, [clearGithubImportTimer, clearGithubResumeTimeout]); + + const startAiProcessing = useCallback(() => { + startImportFlow('ai'); + }, [startImportFlow]); + + const handleExperienceLevelSelect = useCallback( + (level: keyof typeof UserExperienceLevel) => { + if (githubImportPhase !== 'awaitingSeniority') { + return; + } + + clearGithubResumeTimeout(); + setSelectedExperienceLevel(level); + setGithubImportProgress((prev) => Math.max(prev, 72)); + setGithubImportPhase('confirmingSeniority'); + + githubResumeTimeoutRef.current = window.setTimeout(() => { + setGithubImportPhase('finishing'); + }, 420); + }, + [clearGithubResumeTimeout, githubImportPhase], + ); + + useEffect(() => { + applyThemeMode(ThemeMode.Dark); + return () => { + applyThemeMode(); + }; + }, [applyThemeMode]); + + useEffect(() => { + const raf = requestAnimationFrame(() => setMounted(true)); + return () => cancelAnimationFrame(raf); + }, []); + + useEffect(() => { + if (!mounted) { + return undefined; + } + let idleTimer: number | null = null; + let revealTimer: ReturnType | null = null; + + const revealTags = () => { + revealTimer = setTimeout(() => setTagsReady(true), 180); + }; + + if ('requestIdleCallback' in window) { + idleTimer = window.requestIdleCallback(revealTags, { timeout: 1400 }); + } else { + revealTimer = setTimeout(() => setTagsReady(true), 1200); + } + + return () => { + if (idleTimer !== null && 'cancelIdleCallback' in window) { + window.cancelIdleCallback(idleTimer); + } + if (revealTimer !== null) { + window.clearTimeout(revealTimer); + } + }; + }, [mounted]); + + useEffect(() => { + const anyModalOpen = + showSignupChooser || + showSignupPrompt || + showGithubImportFlow || + showAuthSignup || + showExtensionPromo || + githubImportExiting; + + if (anyModalOpen) { + prevBodyOverflowRef.current = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = prevBodyOverflowRef.current; + prevBodyOverflowRef.current = ''; + } + return () => { + document.body.style.overflow = prevBodyOverflowRef.current; + prevBodyOverflowRef.current = ''; + }; + }, [ + showSignupChooser, + showSignupPrompt, + showGithubImportFlow, + showAuthSignup, + showExtensionPromo, + githubImportExiting, + ]); + + useEffect(() => { + const signupQueryParam = router.query.onbSignup; + const shouldOpenFromHeader = + signupQueryParam === '1' || + (Array.isArray(signupQueryParam) && signupQueryParam.includes('1')); + if (!shouldOpenFromHeader) { + return; + } + + setShowSignupChooser(true); + + const { onbSignup, ...restQuery } = router.query; + router.replace( + { + pathname: router.pathname, + query: restQuery, + }, + undefined, + { shallow: true }, + ); + }, [router]); + + // Parallax scroll: shift hero layers at different speeds + useEffect(() => { + if (!mounted) { + return undefined; + } + + // Keep intro order stable: hero settles before feed animates in. + const timer = window.setTimeout(() => { + setFeedVisible(true); + }, 1400); + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + (entry.target as HTMLElement).classList.add('onb-revealed'); + observer.unobserve(entry.target); + } + }); + }, + { rootMargin: '0px 0px -40px 0px', threshold: 0.05 }, + ); + + const observeFeedArticles = () => { + document + .querySelectorAll('.onb-feed-stage article') + .forEach((article, i) => { + if (!article.dataset.onbRevealDelay) { + article.style.setProperty( + '--reveal-delay', + `${Math.min(i * 60, 400)}ms`, + ); + // eslint-disable-next-line no-param-reassign + article.dataset.onbRevealDelay = 'true'; + } + + if (article.classList.contains('onb-revealed')) { + return; + } + + observer.observe(article); + }); + }; + + observeFeedArticles(); + + const mutationObserver = new MutationObserver((mutations) => { + // Only re-observe when actual
elements (or wrappers containing + // them) are added. This prevents a feedback loop with the engagement + // animation, which appends