diff --git a/packages/shared/src/components/auth/AuthOptionsInner.tsx b/packages/shared/src/components/auth/AuthOptionsInner.tsx index 6c18a2f66fe..2faa57a0ea8 100644 --- a/packages/shared/src/components/auth/AuthOptionsInner.tsx +++ b/packages/shared/src/components/auth/AuthOptionsInner.tsx @@ -135,6 +135,7 @@ function AuthOptionsInner({ onboardingSignupButton, hideLoginLink, compact, + splitSignupStyle, autoTriggerProvider, socialProviderScopes, }: AuthOptionsProps): ReactElement { @@ -765,6 +766,7 @@ function AuthOptionsInner({ onboardingSignupButton={onboardingSignupButton} hideLoginLink={hideLoginLink} compact={compact} + splitSignupStyle={splitSignupStyle} /> diff --git a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx index 2a7cad999f8..6c10bdda263 100644 --- a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx +++ b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx @@ -1,11 +1,12 @@ import type { ReactElement } from 'react'; import React, { useEffect } from 'react'; +import classNames from 'classnames'; import type { AuthFormProps } from './common'; import { providerMap } from './common'; import OrDivider from './OrDivider'; import { useLogContext } from '../../contexts/LogContext'; import type { AuthTriggersType } from '../../lib/auth'; -import { AuthEventNames } from '../../lib/auth'; +import { AuthEventNames, AuthTriggers } from '../../lib/auth'; import type { ButtonProps } from '../buttons/Button'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { isIOSNative } from '../../lib/func'; @@ -36,6 +37,7 @@ interface OnboardingRegistrationFormProps extends AuthFormProps { onboardingSignupButton?: ButtonProps<'button'>; hideLoginLink?: boolean; compact?: boolean; + splitSignupStyle?: boolean; } export const isWebView = (): boolean => { @@ -87,14 +89,17 @@ export const isWebView = (): boolean => { return isInAppBrowser || advancedInAppDetection(); }; -const getSignupProviders = () => { +const getSignupProviders = (preferGithub: boolean) => { if (isIOSNative()) { return [providerMap.google, providerMap.apple]; } if (isWebView()) { return [providerMap.github]; } - return [providerMap.google, providerMap.github]; + // Developer-first audiences convert better when GitHub leads the OAuth list. + return preferGithub + ? [providerMap.github, providerMap.google] + : [providerMap.google, providerMap.github]; }; export const OnboardingRegistrationForm = ({ @@ -108,8 +113,10 @@ export const OnboardingRegistrationForm = ({ onboardingSignupButton, hideLoginLink, compact, + splitSignupStyle = false, }: OnboardingRegistrationFormProps): ReactElement => { const { logEvent } = useLogContext(); + const isOnboardingTrigger = trigger === AuthTriggers.Onboarding; const trackOpenSignup = () => { logEvent({ @@ -130,13 +137,97 @@ export const OnboardingRegistrationForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const tertiarySignupButtonClass = + '!w-full !border !border-border-subtlest-tertiary !text-white'; + + const getEmailButtonClass = (): string => { + if (compact) { + return 'mb-4'; + } + if (isOnboardingTrigger && !splitSignupStyle) { + return 'mb-3'; + } + return 'mb-8'; + }; + + const emailButtonLabel = splitSignupStyle + ? 'Create account' + : 'Continue with email'; + + const emailButton = ( + + ); + + const getMemberAlreadyContainerClass = (): string => { + if (isOnboardingTrigger) { + return 'mx-auto mt-5 text-center text-text-secondary typo-callout'; + } + return 'mx-auto mt-6 text-center text-text-secondary typo-callout'; + }; + + const memberAlready = !hideLoginLink && !splitSignupStyle && ( + onExistingEmail?.('')} + className={{ + container: getMemberAlreadyContainerClass(), + login: '!text-inherit', + }} + /> + ); + + const splitSignInSection = splitSignupStyle && !hideLoginLink && ( +
+

+ Already have an account? +

+ +
+ ); + const disclaimer = ( + + ); + return (
    - {getSignupProviders().map((provider) => ( + {getSignupProviders(isOnboardingTrigger).map((provider) => (
  • ))} @@ -156,36 +249,26 @@ export const OnboardingRegistrationForm = ({ className={{ text: 'text-text-tertiary typo-footnote', }} - label="OR" + label={isOnboardingTrigger ? 'or' : 'OR'} /> -
    - {!hideLoginLink && ( - onExistingEmail?.('')} - className={{ - container: - 'mx-auto mt-6 text-center text-text-secondary typo-callout', - login: '!text-inherit', - }} - /> - )} - - -
    + {emailButton} + {splitSignInSection} + {memberAlready} +
+ ) : ( +
+ {memberAlready} + {disclaimer} + {emailButton} +
+ )} ); }; diff --git a/packages/shared/src/components/auth/common.tsx b/packages/shared/src/components/auth/common.tsx index c29c527d399..e60ee6926b7 100644 --- a/packages/shared/src/components/auth/common.tsx +++ b/packages/shared/src/components/auth/common.tsx @@ -126,6 +126,8 @@ export interface AuthOptionsProps { onboardingSignupButton?: ButtonProps<'button'>; hideLoginLink?: boolean; compact?: boolean; + /** X-style split onboarding: "Sign up with", "Create account", Sign in button */ + splitSignupStyle?: boolean; autoTriggerProvider?: string; socialProviderScopes?: string[]; } diff --git a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx new file mode 100644 index 00000000000..579576007f9 --- /dev/null +++ b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx @@ -0,0 +1,834 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import Logo, { LogoPosition } from '../../../components/Logo'; +import { FooterLinks } from '../../../components/footer/FooterLinks'; +import SignupDisclaimer from '../../../components/auth/SignupDisclaimer'; +import type { AuthOptionsProps } from '../../../components/auth/common'; +import { ErrorBoundary } from '../../../components/ErrorBoundary'; +import { ArticleGrid } from '../../../components/cards/article/ArticleGrid'; +import { ActiveFeedNameContext } from '../../../contexts/ActiveFeedNameContext'; +import { SharedFeedPage } from '../../../components/utilities'; +import { gqlClient } from '../../../graphql/common'; +import { MOST_UPVOTED_FEED_QUERY } from '../../../graphql/feed'; +import type { Post } from '../../../graphql/posts'; +import { + ThemeMode, + useSettingsContext, +} from '../../../contexts/SettingsContext'; +const HERO_STYLES = ` +.onb-bg { + background: + radial-gradient(ellipse 65% 50% at 15% 18%, + color-mix(in srgb, var(--theme-accent-cabbage-default) 8%, transparent) 0%, + transparent 65%), + radial-gradient(ellipse 55% 45% at 88% 32%, + color-mix(in srgb, var(--theme-accent-water-default) 7%, transparent) 0%, + transparent 70%), + var(--theme-background-default); +} +.onb-orb { + position: absolute; + border-radius: 9999px; + filter: blur(110px); + mix-blend-mode: screen; + pointer-events: none; + opacity: 0.55; + animation: onb-breathe 22s ease-in-out infinite; +} +.onb-orb--delay { animation-delay: -8s; } +@keyframes onb-breathe { + 0%, 100% { opacity: 0.48; } + 50% { opacity: 0.68; } +} +@media (prefers-reduced-motion: reduce) { + .onb-orb { animation: none; opacity: 0.55; } +} +.onb-form-halo { + background: + radial-gradient( + ellipse 78% 55% at 50% 92%, + rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 0.98) 20%, + rgba(0, 0, 0, 0.9) 36%, + rgba(0, 0, 0, 0.7) 52%, + rgba(0, 0, 0, 0.4) 68%, + rgba(0, 0, 0, 0.15) 82%, + transparent 94% + ); +} +.onb-center-halo { + background: + radial-gradient( + ellipse 55% 36% at 50% 54%, + rgba(0, 0, 0, 0.96) 0%, + rgba(0, 0, 0, 0.88) 22%, + rgba(0, 0, 0, 0.68) 42%, + rgba(0, 0, 0, 0.42) 60%, + rgba(0, 0, 0, 0.18) 76%, + transparent 92% + ); +} +.onb-bottom-vignette { + background: linear-gradient( + to bottom, + transparent 0%, + transparent 32%, + rgba(0, 0, 0, 0.45) 56%, + rgba(0, 0, 0, 0.85) 78%, + rgba(0, 0, 0, 1) 100% + ); +} +.onb-top-fade { + background: linear-gradient( + to bottom, + rgba(8, 8, 12, 0.55) 0%, + rgba(8, 8, 12, 0.12) 28%, + transparent 44% + ); +} +.onb-prod-scrim { + background: linear-gradient( + to top, + rgba(8, 8, 12, 0.78) 0%, + rgba(8, 8, 12, 0.55) 28%, + rgba(8, 8, 12, 0.25) 58%, + rgba(8, 8, 12, 0.05) 82%, + transparent 100% + ); +} +.onb-headline { text-shadow: 0 2px 32px rgba(0, 0, 0, 0.95), 0 0 64px rgba(0, 0, 0, 0.6); } +.onb-grid-mask { + -webkit-mask-image: + radial-gradient( + ellipse 78% 58% at 50% 95%, + transparent 0%, + transparent 16%, + rgba(0, 0, 0, 0.45) 36%, + rgba(0, 0, 0, 0.95) 62%, + black 100% + ); + mask-image: + radial-gradient( + ellipse 78% 58% at 50% 95%, + transparent 0%, + transparent 16%, + rgba(0, 0, 0, 0.45) 36%, + rgba(0, 0, 0, 0.95) 62%, + black 100% + ); +} +.onb-cover-shade { + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 60%, + rgba(0, 0, 0, 0.35) 100% + ); +} +.onb-split-grid-mask { + -webkit-mask-image: + linear-gradient( + to right, + black 0%, + black 55%, + rgba(0, 0, 0, 0.75) 78%, + transparent 100% + ); + mask-image: + linear-gradient( + to right, + black 0%, + black 55%, + rgba(0, 0, 0, 0.75) 78%, + transparent 100% + ); +} +.onb-split-left-fade { + background: + linear-gradient( + to right, + transparent 0%, + rgba(0, 0, 0, 0.25) 55%, + rgba(0, 0, 0, 0.72) 82%, + rgba(8, 8, 12, 1) 100% + ); +} +.onb-split-left-water-glow { + background: + radial-gradient( + ellipse 85% 65% at 18% 100%, + color-mix(in srgb, var(--theme-accent-water-default) 14%, transparent) 0%, + color-mix(in srgb, var(--theme-accent-water-default) 5%, transparent) 42%, + transparent 72% + ); +} +.onb-bg-split { + background: var(--theme-background-default); +} +.onb-split-right-panel { + background: var(--theme-background-default); +} +`; + +// ============================================================= +// Variant A — Cards: real daily.dev feed cards +// ============================================================= + +type FeedQueryResult = { + page: { edges: Array<{ node: Post }> }; +}; + +const noop = (): void => undefined; + +const useExplorePosts = (): Post[] => { + const { data } = useQuery({ + queryKey: ['onboarding-explore-feed'], + queryFn: async () => { + const res = await gqlClient.request( + MOST_UPVOTED_FEED_QUERY, + { + first: 30, + period: 7, + loggedIn: false, + supportedTypes: ['article', 'video:youtube'], + }, + ); + return res.page.edges + .map((edge) => edge.node) + .filter((post): post is Post => !!post && !!post.id && !!post.title) + .map((post) => ({ ...post, clickbaitTitleDetected: false })); + }, + staleTime: 1000 * 60 * 10, + retry: 1, + }); + return data ?? []; +}; + +const ExplorePostCard = ({ post }: { post: Post }): ReactElement => ( + + + +); + +const CardsBackground = ({ + splitMode = false, +}: { + splitMode?: boolean; +}): ReactElement => { + const posts = useExplorePosts(); + const feedMaskClass = splitMode + ? 'onb-split-grid-mask inset-0 -z-1' + : 'onb-grid-mask inset-0 -z-1'; + + return ( + +
+
+ {posts.map((post) => ( + + ))} +
+
+
+ ); +}; + +// ============================================================= +// Variant C — Desk: full-cover photo backdrop +// ============================================================= + +const DESK_HERO_SRC = '/assets/onboarding-hero-desk.webp'; +const DESK_HERO_SRCSET = [ + '/assets/onboarding-hero-desk-1280.webp 1280w', + '/assets/onboarding-hero-desk-1920.webp 1920w', + '/assets/onboarding-hero-desk-2560.webp 2560w', +].join(', '); + +const DeskBackground = (): ReactElement => ( +
+ + + + +
+); + +// ============================================================= +// Image mode — production signup image (toggle overrides each variant) +// ============================================================= + +// The actual desktop + mobile artwork used on the live signup wall. +// Source: cloudinaryOnboardingFullBackgroundDesktop / Mobile in shared/lib/image. +const PROD_IMAGE_DESKTOP = + 'https://media.daily.dev/image/upload/s--r2ffZPB4--/f_auto/v1716969841/dailydev_where_developers_suffer_together_sfvfog'; +const PROD_IMAGE_MOBILE = + 'https://media.daily.dev/image/upload/s--EwsBTBt6--/f_auto/v1716969841/dailydev_where_developers_suffer_together_mobile_shkn1w'; + +const ProdSignupBackground = ({ + splitMode = false, + visible = true, +}: { + splitMode?: boolean; + visible?: boolean; +}): ReactElement => ( +
+ + + + +
+); + +// ============================================================= +// Variant registry & switcher +// ============================================================= + +type VariantId = 'cards' | 'desk' | 'split'; +type ImageMode = 'original' | 'prod' | 'colors'; + +type VariantDef = { + id: VariantId; + label: string; + render: (mode: ImageMode) => ReactElement | null; +}; + +const renderVariantBackground = ( + variant: VariantId, + mode: ImageMode, +): ReactElement => { + const splitMode = variant === 'split'; + const showOriginal = mode === 'original'; + const showProd = mode === 'prod'; + let originalLayer: ReactElement | null = null; + if (variant === 'cards' || variant === 'split') { + originalLayer = ; + } else { + originalLayer = ; + } + return ( + <> + {showOriginal && originalLayer} + + + ); +}; + +const VARIANTS: VariantDef[] = [ + { + id: 'cards', + label: 'Cards', + render: (mode) => renderVariantBackground('cards', mode), + }, + { + id: 'split', + label: 'X', + render: (mode) => renderVariantBackground('split', mode), + }, + { + id: 'desk', + label: 'Desk', + render: (mode) => renderVariantBackground('desk', mode), + }, +]; + +const IMAGE_MODES: Array<{ id: ImageMode; label: string }> = [ + { id: 'original', label: 'Original' }, + { id: 'prod', label: 'Prod image' }, + { id: 'colors', label: 'Colors only' }, +]; + +const VARIANT_STORAGE_KEY = 'onb-hero-variant'; +const IMAGE_MODE_STORAGE_KEY = 'onb-hero-mode'; +const VARIANT_IDS = new Set(VARIANTS.map((v) => v.id)); +const IMAGE_MODE_IDS = new Set(IMAGE_MODES.map((m) => m.id)); + +const isVariantId = (value: string | null): value is VariantId => + !!value && VARIANT_IDS.has(value as VariantId); + +const isImageMode = (value: string | null): value is ImageMode => + !!value && IMAGE_MODE_IDS.has(value as ImageMode); + +const readInitialVariant = (): VariantId => { + if (typeof window === 'undefined') { + return VARIANTS[0].id; + } + const fromUrl = new URLSearchParams(window.location.search).get('variant'); + if (isVariantId(fromUrl)) { + return fromUrl; + } + const fromStorage = window.localStorage.getItem(VARIANT_STORAGE_KEY); + if (isVariantId(fromStorage)) { + return fromStorage; + } + return VARIANTS[0].id; +}; + +const readInitialImageMode = (): ImageMode => { + if (typeof window === 'undefined') { + return 'original'; + } + const fromUrl = new URLSearchParams(window.location.search).get('mode'); + if (isImageMode(fromUrl)) { + return fromUrl; + } + const fromStorage = window.localStorage.getItem(IMAGE_MODE_STORAGE_KEY); + if (isImageMode(fromStorage)) { + return fromStorage; + } + return 'original'; +}; + +const VARIANT_SWITCHER_Z_INDEX = 9999; + +const VariantSwitcher = ({ + value, + onChange, + imageMode, + onImageModeChange, +}: { + value: VariantId; + onChange: (next: VariantId) => void; + imageMode: ImageMode; + onImageModeChange: (next: ImageMode) => void; +}): ReactElement => ( +
+
+ + Variant + +
+ {VARIANTS.map((variant) => { + const active = variant.id === value; + return ( + + ); + })} +
+
+
+
+ + Image + +
+ {IMAGE_MODES.map((mode) => { + const active = mode.id === imageMode; + return ( + + ); + })} +
+
+
+); + +// ============================================================= +// Main hero +// ============================================================= + +type Props = { + children: ReactNode; + isFormExpanded?: boolean; + headline?: string | null; +}; + +const DEFAULT_HEADLINE = 'The homepage every developer deserves.'; +const SIGNUP_CONTENT_MAX_W = 'max-w-[360px]'; + +export const OnboardingSignupHero = ({ + children, + isFormExpanded = false, + headline = DEFAULT_HEADLINE, +}: Props): ReactElement => { + const { applyThemeMode } = useSettingsContext(); + const [variantId, setVariantId] = useState(VARIANTS[0].id); + const [imageMode, setImageMode] = useState('original'); + + useEffect(() => { + setVariantId(readInitialVariant()); + setImageMode(readInitialImageMode()); + }, []); + + useEffect(() => { + applyThemeMode(ThemeMode.Dark); + return () => { + applyThemeMode(); + }; + }, [applyThemeMode]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + window.localStorage.setItem(VARIANT_STORAGE_KEY, variantId); + }, [variantId]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + window.localStorage.setItem(IMAGE_MODE_STORAGE_KEY, imageMode); + }, [imageMode]); + + const activeVariant = VARIANTS.find((v) => v.id === variantId) ?? VARIANTS[0]; + const isSplitLayout = variantId === 'split'; + const isDeskVariant = variantId === 'desk'; + const isProdImageMode = imageMode === 'prod'; + + const signupForm = + isSplitLayout && React.isValidElement(children) + ? React.cloneElement(children, { splitSignupStyle: true }) + : children; + + const splitSignupColumn = ( + <> +
+
+ + + {!isFormExpanded && headline && ( +

+ {headline} +

+ )} + + {signupForm} +
+
+ +
+
+ +
+ +
+ + ); + + return ( +
+