diff --git a/packages/shared/src/components/LoginButton.tsx b/packages/shared/src/components/LoginButton.tsx index 4d29121e4a..c8e5ee3986 100644 --- a/packages/shared/src/components/LoginButton.tsx +++ b/packages/shared/src/components/LoginButton.tsx @@ -58,6 +58,7 @@ export default function LoginButton({ onClick={() => onClick(ButtonCopy.Signup)} variant={ButtonVariant.Primary} className={className?.button} + data-header-signup > {ButtonCopy.Signup} diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 20442a4e97..70e5d3ab52 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -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, @@ -192,6 +195,7 @@ function MainLayoutComponent({ hasBanner={isBannerAvailable} sidebarRendered={sidebarRendered} additionalButtons={additionalButtons} + hideSearchField={hideSearchField} onLogoClick={onLogoClick} />
)} {children} diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx index 3f1885feeb..7ef2762eb8 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -20,6 +20,7 @@ export interface MainLayoutHeaderProps { sidebarRendered?: boolean; additionalButtons?: ReactNode; onLogoClick?: (e: React.MouseEvent) => unknown; + hideSearchField?: boolean; } const SearchPanel = dynamic( @@ -39,6 +40,7 @@ function MainLayoutHeader({ sidebarRendered, additionalButtons, onLogoClick, + hideSearchField, }: MainLayoutHeaderProps): ReactElement { const { loadedSettings } = useSettingsContext(); const { streak, isStreaksEnabled } = useReadingStreak(); @@ -56,6 +58,7 @@ function MainLayoutHeader({ const RenderSearchPanel = useCallback( () => + !hideSearchField && loadedSettings && ( ), - [loadedSettings, isSearchPage, hasBanner], + [loadedSettings, isSearchPage, hasBanner, hideSearchField], ); if (loadedSettings && !isLaptop) { 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 037c6f5b3f..287e3142f3 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 && (
({ + 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; +}; + +type LiveFloater = { + id: number; + text: string; + color: string; + x: number; + y: 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 [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 [liveFloaters, setLiveFloaters] = useState([]); + const floaterIdRef = useRef(0); + const prevBodyOverflowRef = useRef(''); + 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 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(); + 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 || + 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, + showExtensionPromo, + githubImportExiting, + ]); + + useEffect(() => { + const onHeaderSignupClick = (event: MouseEvent) => { + const { target } = event; + if (!(target instanceof Element)) { + return; + } + + const trigger = target.closest('button, a'); + if (!(trigger instanceof HTMLElement)) { + return; + } + + // Intercept only top header signup actions on this page. + if (!trigger.closest('header')) { + return; + } + + // Use a data attribute marker instead of brittle textContent matching. + // LoginButton adds data-header-signup to the sign-up button. + const isSignupTrigger = 'headerSignup' in trigger.dataset; + + if (!isSignupTrigger) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + setShowSignupChooser(true); + }; + + document.addEventListener('click', onHeaderSignupClick, true); + return () => { + document.removeEventListener('click', onHeaderSignupClick, true); + }; + }, []); + + // 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(() => { + observeFeedArticles(); + }); + const feedContainer = + document.querySelector('.onb-feed-stage') ?? document.body; + // subtree: false — only watch for direct article additions to the feed + // container, not for DOM mutations inside articles (which the engagement + // animation makes, causing a feedback loop with subtree: true). + mutationObserver.observe(feedContainer, { + childList: true, + subtree: false, + }); + + return () => { + window.clearTimeout(timer); + mutationObserver.disconnect(); + observer.disconnect(); + }; + }, [mounted]); + + useEffect(() => { + return () => { + clearGithubImportTimer(); + clearGithubResumeTimeout(); + }; + }, [clearGithubImportTimer, clearGithubResumeTimeout]); + + useEffect(() => { + if (!feedReadyState) { + return undefined; + } + + const redirectTimer = window.setTimeout(() => { + router.replace('/'); + }, 5000); + + return () => { + window.clearTimeout(redirectTimer); + }; + }, [feedReadyState, router]); + + useEffect(() => { + if (!showGithubImportFlow) { + return undefined; + } + + if (githubImportPhase !== 'running' && githubImportPhase !== 'finishing') { + return undefined; + } + + clearGithubImportTimer(); + + githubImportTimerRef.current = window.setInterval(() => { + setGithubImportProgress((prev) => { + const increment = githubImportPhase === 'running' ? 2 : 3; + const next = Math.min(100, prev + increment); + + if (githubImportPhase === 'running' && next >= 68) { + clearGithubImportTimer(); + setGithubImportPhase('awaitingSeniority'); + return 68; + } + + if (githubImportPhase === 'finishing' && next >= 100) { + clearGithubImportTimer(); + setGithubImportPhase('complete'); + setTimeout(() => { + setGithubImportExiting(true); + setTimeout(() => { + setShowGithubImportFlow(false); + setGithubImportExiting(false); + setShowExtensionPromo(true); + }, 350); + }, 600); + return 100; + } + + return next; + }); + }, 120); + + return () => { + clearGithubImportTimer(); + }; + }, [clearGithubImportTimer, githubImportPhase, showGithubImportFlow]); + + useEffect(() => { + const node = panelSentinelRef.current; + if (!node) { + return undefined; + } + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setPanelVisible(true); + } + }, + { rootMargin: '0px 0px 200px 0px', threshold: 0 }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const stage = panelStageRef.current; + if (!stage) { + return undefined; + } + + const prefersReduced = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + if (prefersReduced) { + setPanelStageProgress(1); + return undefined; + } + + let frame = 0; + const update = () => { + frame = 0; + const rect = stage.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const start = viewportHeight * 0.86; + const end = viewportHeight * 0.05; + const raw = (start - rect.top) / (start - end); + const clamped = Math.min(1, Math.max(0, raw)); + setPanelStageProgress((prev) => + Math.abs(prev - clamped) > 0.01 ? clamped : prev, + ); + }; + + const onScrollOrResize = () => { + if (frame) { + return; + } + frame = requestAnimationFrame(update); + }; + + update(); + window.addEventListener('scroll', onScrollOrResize, { passive: true }); + window.addEventListener('resize', onScrollOrResize); + + return () => { + if (frame) { + cancelAnimationFrame(frame); + } + window.removeEventListener('scroll', onScrollOrResize); + window.removeEventListener('resize', onScrollOrResize); + }; + }, []); + + useEffect(() => { + const prefersReduced = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + if (prefersReduced) { + return undefined; + } + + let ticking = false; + const onScroll = () => { + scrollY.current = window.scrollY; + if (!ticking) { + ticking = true; + requestAnimationFrame(() => { + const hero = heroRef.current; + if (hero) { + const y = scrollY.current; + hero.style.setProperty('--scroll-y', `${y}`); + } + ticking = false; + }); + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + // Cursor-tracking glow on personalization panel + useEffect(() => { + const box = panelBoxRef.current; + if (!box) { + return undefined; + } + + const onMove = (e: MouseEvent) => { + const rect = box.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + box.style.setProperty('--mouse-x', `${x}px`); + box.style.setProperty('--mouse-y', `${y}px`); + }; + box.addEventListener('mousemove', onMove); + return () => box.removeEventListener('mousemove', onMove); + }, []); + useEffect(() => { + const shouldRun = + feedVisible && + !feedReadyState && + !showSignupChooser && + !showSignupPrompt && + !showGithubImportFlow && + !showExtensionPromo && + !githubImportExiting; + if (!shouldRun) { + return undefined; + } + + const prefersReduced = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + if (prefersReduced) { + return undefined; + } + + const timeouts = new Set(); + const addTimeout = (fn: () => void, delay: number) => { + const id = window.setTimeout(() => { + timeouts.delete(id); + fn(); + }, delay); + timeouts.add(id); + }; + + const getVisibleArticles = () => { + return Array.from( + document.querySelectorAll( + '.onb-feed-stage article.onb-revealed:not([data-eng-active="true"])', + ), + ).filter((article) => { + const rect = article.getBoundingClientRect(); + return rect.top > 100 && rect.bottom < window.innerHeight - 100; + }); + }; + + const getButtonWrapper = (article: Element, suffix: string) => { + const btn = article.querySelector(`[id$="${suffix}"]`); + return btn ? btn.closest('.btn-quaternary') || btn.parentElement : null; + }; + + const findCounter = (wrapper: Element) => { + const spans = Array.from(wrapper.querySelectorAll('span')); + return spans.find((s) => { + const t = s.textContent?.trim(); + return t && /^[\d][.\dkKmM]*$/.test(t) && !s.querySelector('span'); + }); + }; + + const ensureCounter = (wrapper: Element, seed: number) => { + let counter = findCounter(wrapper); + if (!counter) { + const label = document.createElement('label'); + label.className = + 'flex cursor-pointer items-center pl-1 font-bold typo-callout'; + counter = document.createElement('span'); + counter.className = + 'flex h-5 min-w-[1ch] flex-col overflow-hidden tabular-nums typo-footnote'; + counter.textContent = String(seed); + label.appendChild(counter); + wrapper.appendChild(label); + } + return counter; + }; + + const formatCount = (n: number) => { + if (n >= 1000000) { + return `${(n / 1000000).toFixed(1).replace(/\.0$/, '')}m`; + } + if (n >= 1000) { + return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`; + } + return String(n); + }; + + const parseCount = (text: string) => { + const clean = text.trim().toLowerCase(); + if (/^\d+$/.test(clean)) { + return parseInt(clean, 10); + } + const m = clean.match(/^([\d.]+)([km])$/); + if (!m) { + return null; + } + const n = parseFloat(m[1]); + return m[2] === 'k' ? n * 1000 : n * 1000000; + }; + + const runStream = () => { + const articles = getVisibleArticles(); + if (!articles.length) { + addTimeout(runStream, 1000); + return; + } + + const article = articles[Math.floor(Math.random() * articles.length)]; + article.setAttribute('data-eng-active', 'true'); + + const isUpvote = Math.random() < 0.7; + const suffix = isUpvote ? '-upvote-btn' : '-comment-btn'; + const wrapper = getButtonWrapper(article, suffix); + const btn = article.querySelector(`[id$="${suffix}"]`); + + if (!wrapper || !btn) { + article.removeAttribute('data-eng-active'); + addTimeout(runStream, 500); + return; + } + + const wrapperEl = wrapper as HTMLElement; + // Add CSS class instead of direct style mutation — the class provides + // position:relative via the stylesheet without forcing a style recalculation. + wrapperEl.classList.add('onb-eng-pos-relative'); + + const counter = ensureCounter( + wrapper, + isUpvote + ? 4 + Math.floor(Math.random() * 50) + : 1 + Math.floor(Math.random() * 10), + ); + + const activeClass = isUpvote + ? 'onb-eng-active-upvote' + : 'onb-eng-active-comment'; + const color = isUpvote + ? 'var(--theme-actions-upvote-default)' + : 'var(--theme-actions-comment-default)'; + + wrapperEl.classList.add(activeClass); + + const numIncrements = 2 + Math.floor(Math.random() * 5); // 2 to 6 increments + const increments: number[] = []; + for (let i = 0; i < numIncrements; i += 1) { + const r = Math.random(); + if (r < 0.55) { + increments.push(1 + Math.floor(Math.random() * 2)); + } // 1-2 (55%) + else if (r < 0.85) { + increments.push(3 + Math.floor(Math.random() * 4)); + } // 3-6 (30%) + else { + increments.push(7 + Math.floor(Math.random() * 12)); + } // 7-18 (15%) + } + + let delayAcc = 0; + + increments.forEach((inc) => { + addTimeout(() => { + // Restart the pulse animation without reading layout (offsetWidth + // forces a synchronous reflow). Setting animationName to 'none' is a + // write-only style operation, then the next rAF restores it so the + // animation re-runs from the start. + btn.classList.remove('onb-eng-pulse'); + (btn as HTMLElement).style.animationName = 'none'; + requestAnimationFrame(() => { + (btn as HTMLElement).style.animationName = ''; + btn.classList.add('onb-eng-pulse'); + }); + + const currentVal = parseCount(counter.textContent || '') || 0; + counter.textContent = formatCount(currentVal + inc); + + // Create floater via React state so it renders in a fixed-position + // overlay rather than appending DOM nodes directly to the article. + const counterRect = counter.getBoundingClientRect(); + floaterIdRef.current += 1; + const newFloaterId = floaterIdRef.current; + setLiveFloaters((prev) => [ + ...prev, + { + id: newFloaterId, + text: `+${inc}`, + color, + x: counterRect.left + counterRect.width / 2, + y: counterRect.top, + }, + ]); + addTimeout(() => { + setLiveFloaters((prev) => + prev.filter((f) => f.id !== newFloaterId), + ); + }, 2500); + }, delayAcc); + + delayAcc += 350 + Math.random() * 650; // 0.35s to 1.0s between pops + }); + + addTimeout(() => { + wrapperEl.classList.remove(activeClass); + article.removeAttribute('data-eng-active'); + }, delayAcc + 600); + + // Schedule next stream on this "thread" + addTimeout(runStream, delayAcc + 1000 + Math.random() * 1500); + }; + + // Start 4 concurrent streams for higher density + addTimeout(runStream, 200); + addTimeout(runStream, 800); + addTimeout(runStream, 1500); + addTimeout(runStream, 2200); + + return () => { + timeouts.forEach(clearTimeout); + timeouts.clear(); + // Clear any floaters that didn't finish their cleanup timer — they'd + // persist indefinitely because their cleanup timeouts were just cancelled. + setLiveFloaters([]); + }; + }, [ + feedVisible, + feedReadyState, + showExtensionPromo, + showGithubImportFlow, + showSignupChooser, + showSignupPrompt, + githubImportExiting, + ]); + + const recommendedTopics = useMemo(() => { + if (!aiPrompt.trim()) { + return []; + } + + const lower = aiPrompt.toLowerCase(); + + return SELECTABLE_TOPICS.map((topic) => { + const labelLower = topic.label.toLowerCase(); + const keywords = labelLower.split(/[\s&/.+-]+/).filter(Boolean); + const hasDirect = lower.includes(labelLower); + const score = keywords.reduce( + (acc, kw) => { + if (kw.length < 3) { + return acc; + } + return lower.includes(kw) ? acc + kw.length : acc; + }, + hasDirect ? 100 : 0, + ); + + return { ...topic, score }; + }) + .filter((topic) => topic.score > 0 && !selectedTopics.has(topic.label)) + .sort((a, b) => b.score - a.score) + .slice(0, 6); + }, [aiPrompt, selectedTopics]); + + const confettiParticles = useMemo( + () => (feedReadyState ? buildConfettiParticles() : []), + [feedReadyState], + ); + useEffect(() => { + const hero = heroRef.current; + if (!hero) { + return undefined; + } + + const observer = new IntersectionObserver( + ([entry]) => { + hero.classList.toggle('onb-hero-offscreen', !entry.isIntersecting); + }, + { threshold: 0 }, + ); + observer.observe(hero); + return () => observer.disconnect(); + }, []); + + const [detectedBrowser, setDetectedBrowser] = useState(BrowserName.Chrome); + useEffect(() => { + setDetectedBrowser(getCurrentBrowserName()); + }, []); + const isEdgeBrowser = detectedBrowser === BrowserName.Edge; + const extensionImages = + cloudinaryOnboardingExtension[ + isEdgeBrowser ? BrowserName.Edge : BrowserName.Chrome + ]; + + const dismissExtensionPromo = useCallback(() => { + setShowExtensionPromo(false); + setFeedReadyState(true); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + const closeSignupChooser = useCallback(() => { + setShowSignupChooser(false); + }, []); + const openLogin = useCallback(() => { + showLogin({ + trigger: AuthTriggers.MainButton, + options: { isLogin: true }, + }); + }, [showLogin]); + const isAiSetupContext = signupContext === 'ai' || signupContext === 'manual'; + const canStartAiFlow = aiPrompt.trim().length > 0 || selectedTopics.size > 0; + const startGithubFlowFromChooser = useCallback(() => { + setShowSignupChooser(false); + startGithubImportFlow(); + }, [startGithubImportFlow]); + const startAiFlowFromChooser = useCallback(() => { + if (!canStartAiFlow) { + return; + } + setShowSignupChooser(false); + startAiProcessing(); + }, [canStartAiFlow, startAiProcessing]); + const startAiFlowFromSignup = useCallback(() => { + if (!canStartAiFlow) { + return; + } + setShowSignupPrompt(false); + startAiProcessing(); + }, [canStartAiFlow, startAiProcessing]); + + const panelLift = Math.round(panelStageProgress * 60); + const panelRevealOffset = panelVisible ? 40 : 120; + const isAwaitingSeniorityInput = githubImportPhase === 'awaitingSeniority'; + const importSteps = useMemo( + () => + importFlowSource === 'github' ? GITHUB_IMPORT_STEPS : AI_IMPORT_STEPS, + [importFlowSource], + ); + const currentImportStep = useMemo(() => { + if (githubImportPhase === 'awaitingSeniority') { + return 'Waiting for your seniority level'; + } + if (githubImportPhase === 'confirmingSeniority') { + return 'Applying your seniority level'; + } + if (githubImportPhase === 'complete') { + return 'Your feed is ready'; + } + + const upcomingStep = importSteps.find( + (step) => githubImportProgress < step.threshold, + ); + return upcomingStep?.label ?? 'Building personalized feed'; + }, [githubImportPhase, githubImportProgress, importSteps]); + const githubImportBodyPhase = useMemo(() => { + if ( + githubImportPhase === 'running' || + githubImportPhase === 'finishing' || + githubImportPhase === 'confirmingSeniority' || + githubImportPhase === 'complete' + ) { + return 'checklist'; + } + if (githubImportPhase === 'awaitingSeniority') { + return 'seniority'; + } + + return 'default'; + }, [githubImportPhase]); + + useEffect(() => { + if (githubImportBodyPhase === 'default') { + setGithubImportBodyHeight(null); + return undefined; + } + + const contentNode = githubImportBodyContentRef.current; + if (!contentNode) { + return undefined; + } + + const updateHeight = () => { + setGithubImportBodyHeight(contentNode.getBoundingClientRect().height); + }; + + updateHeight(); + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + const resizeObserver = new ResizeObserver(updateHeight); + resizeObserver.observe(contentNode); + + return () => { + resizeObserver.disconnect(); + }; + }, [githubImportBodyPhase]); + + return ( +
+ {/* ── Engagement floaters overlay (React-controlled, fixed position) ── */} + {liveFloaters.length > 0 && ( + + )} + {/* ── Hero ── */} +
+
+ +
+ + +
+
+ {/* Dot grid — shifts subtly with scroll */} +
+ + {/* Floating particles */} +
+
+
+
+
+
+
+ {tagsReady && + RISING_TAGS_DESKTOP.map((tag) => ( + + {tag.label} + + ))} +
+ + {/* Single radial hero glow */} +
+ + {/* Centered text content */} +
+
+ {/* Mobile-only rising tags */} +
+ {tagsReady && + RISING_TAGS_MOBILE.map((tag) => ( + + {tag.label} + + ))} +
+ + {/* Headline */} +
+

+ Join top dev community. +
+ + Build your feed identity. + +

+
+ + {/* Subtext */} +
+

+ Tap into live signals from the global dev community, then lock + your feed to your stack with GitHub import or AI setup. +

+
+ + {/* Hero CTA group */} +
+
+
+ + +
+
+ + {/* Mobile-only bottom rising tags */} +
+ {tagsReady && + RISING_TAGS_MOBILE.map((tag) => ( + + {tag.label} + + ))} +
+
+
+ + {/* ── Full-screen confetti (fixed, above everything) ── */} + {feedReadyState && ( +
+ {confettiParticles.map((p) => { + const sizeMap: Record = { + xl: 'h-4 w-2.5', + lg: 'h-3 w-2', + md: 'h-2.5 w-1.5', + }; + const sizeClass = sizeMap[p.size] ?? 'h-2 w-1'; + const shapeMap: Record = { + circle: 'rounded-full', + star: 'onb-confetti-star', + }; + const shapeClass = shapeMap[p.shape] ?? 'rounded-[1px]'; + return ( + + ); + })} +
+ )} + + {/* ── Feed Ready: Celebration Banner ── */} + {feedReadyState && ( +
+ {/* Radial burst glows — multi-layered */} +
+
+
+ + {/* Sparkle accents */} + {[ + { left: '15%', top: '18%', delay: '200ms', size: 12 }, + { left: '80%', top: '12%', delay: '500ms', size: 16 }, + { left: '25%', top: '65%', delay: '700ms', size: 10 }, + { left: '72%', top: '55%', delay: '400ms', size: 14 }, + { left: '50%', top: '8%', delay: '100ms', size: 18 }, + { left: '90%', top: '40%', delay: '600ms', size: 8 }, + ].map((s) => ( + + + + ))} + +
+ {/* Celebration icon with glow ring */} +
+
+
+ + + + +
+
+ + {/* Headline */} +

+ Your feed is ready +

+

+ Here's how to get the most out of daily.dev +

+ + {/* Action chips */} +
+ {/* Install extension */} + + + {/* Get mobile app */} + + + {/* Enable notifications */} + +
+ + {/* Go to feed */} + +
+
+ )} + + {/* ── Feed ── */} +
+ + + {/* Scroll sentinel — triggers panel at ~50% of feed */} +
+ + {/* ── Personalization Panel ── */} +
+
+ {/* Dark gradient overlay — fades feed out progressively */} +
+
+ +
+
+
+ {/* Section title */} +
+

+ You just explored the global feed. +

+

+ Now build a feed that is truly yours +

+
+ + {/* Two-path layout */} +
+ {/* ── Path A: GitHub ── */} +
+ {/* Animated orb — full-width energy field */} +
+ {/* Radial gradient from top center */} +
+ {/* Wide glow */} +
+ {/* Outer ring */} + + + + {/* Middle ring */} + + + + {/* Inner ring */} + + + + {/* Particles from far away */} + {[ + { + px: '-6rem', + py: '-3.5rem', + dur: '3.0s', + delay: '0s', + color: 'bg-accent-cheese-default', + }, + { + px: '5.5rem', + py: '-4rem', + dur: '3.4s', + delay: '0.5s', + color: 'bg-accent-water-default', + }, + { + px: '-5rem', + py: '3.5rem', + dur: '3.2s', + delay: '1.0s', + color: 'bg-accent-cabbage-default', + }, + { + px: '6rem', + py: '3rem', + dur: '3.6s', + delay: '1.5s', + color: 'bg-accent-onion-default', + }, + { + px: '0.5rem', + py: '-5rem', + dur: '2.8s', + delay: '0.7s', + color: 'bg-accent-cheese-default', + }, + { + px: '-6.5rem', + py: '0.5rem', + dur: '3.1s', + delay: '1.2s', + color: 'bg-accent-water-default', + }, + ].map((p) => ( + + ))} + {/* Center icon with pulse */} +
+ + + +
+
+ +

+ One-click setup +

+

+ Connect GitHub and let our AI do the rest. +

+ +
+ {[ + { + text: 'We spot your stack from GitHub', + icon: 'stack', + }, + { + text: 'AI matches your skills to topics', + icon: 'ai', + }, + { + text: 'Your feed is ready in seconds', + icon: 'feed', + }, + ].map(({ text, icon }) => ( +
+ + {icon === 'stack' && ( + + )} + {icon === 'ai' && ( + + )} + {icon === 'feed' && ( + + )} + + + {text} + +
+ ))} +
+ +
+ +
+
+ +
+

+ Read-only access · No special permissions +

+
+ + {/* ── Path B: Manual ── */} +
+ {/* Static icon zone */} +
+ {/* Radial gradient from top center */} +
+
+ +
+
+ +

+ Tell our AI about yourself +

+

+ Describe your stack and let AI build your feed. +

+ + {/* Textarea */} +
+