From 8c71702e356e9d40dfbd514985e43c39cf601d49 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 29 Jul 2026 16:13:55 +0300 Subject: [PATCH 01/18] feat(onboarding): redesign the post-signup funnel steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One design language across the /onboarding funnel: shared headline scale, 440px rail, top chrome strip (logo + skip), docked glass CTA, and the giveback brand-gradient canvas. The onboarding_chrome experiment adds an edge-aura frame + progress dots as the variant arm. The paid /helloworld funnel is untouched — shared surfaces gate on isOnboarding. Co-Authored-By: Claude Fable 5 --- packages/extension/jest.config.js | 1 + packages/extension/tsconfig.json | 8 +- packages/shared/__mocks__/edgeAuraMock.tsx | 6 + packages/shared/jest.config.js | 1 + packages/shared/package.json | 1 + .../src/components/GrowthBookProvider.tsx | 4 + .../src/components/auth/AuthOptionsInner.tsx | 2 + .../auth/OnboardingRegistrationForm.tsx | 4 +- .../auth/RegistrationFieldsForm.tsx | 41 +- .../shared/src/components/auth/common.tsx | 2 + .../components/feeds/FeedPreviewControls.tsx | 73 +- .../src/components/fields/CardCheckbox.tsx | 116 +-- .../src/components/fields/SearchField.tsx | 11 +- .../shared/src/components/filters/helpers.ts | 4 +- .../onboarding/ContentTypes/ContentTypes.tsx | 106 ++- .../src/components/onboarding/EditTag.tsx | 28 +- .../components/onboarding/OnboardingPWA.tsx | 13 +- .../components/onboarding/ReadingReminder.tsx | 249 +++--- .../src/components/onboarding/common.ts | 32 + .../onboarding/useReadingReminder.ts | 89 +++ .../src/components/plus/PlusListItem.tsx | 7 +- .../components/OnboardingPlusControl.tsx | 81 +- .../onboarding/components/UploadCv.tsx | 30 +- .../shared/FunnelStepBackground.tsx | 178 ++++- .../shared/FunnelStepCtaWrapper.tsx | 160 +++- .../onboarding/shared/FunnelStepDots.tsx | 88 +++ .../onboarding/shared/FunnelStepTopBar.tsx | 63 ++ .../onboarding/shared/FunnelStepper.tsx | 135 ++-- .../shared/OnboardingBackground.tsx | 52 ++ .../onboarding/shared/useOnboardingChrome.ts | 33 + .../steps/FunnelBrowserExtension.tsx | 97 ++- .../onboarding/steps/FunnelContentTypes.tsx | 17 +- .../onboarding/steps/FunnelEditTags.tsx | 27 +- .../onboarding/steps/FunnelInstallPwa.tsx | 13 +- .../onboarding/steps/FunnelPlusCards.tsx | 18 +- .../onboarding/steps/FunnelProfileForm.tsx | 43 +- .../steps/FunnelReadingReminder.tsx | 33 +- .../onboarding/steps/FunnelUploadCv.tsx | 44 +- .../onboarding/steps/FunnelVerifyEmail.tsx | 118 +++ .../src/features/onboarding/steps/index.ts | 1 + .../src/features/onboarding/types/funnel.ts | 19 +- packages/shared/src/lib/featureManagement.ts | 11 + packages/shared/src/styles/base.css | 6 - packages/shared/tsconfig.json | 9 +- .../onboarding/SignupFunnelSteps.stories.tsx | 706 ++++++++++++++++++ .../onboarding/signupFunnel.mocks.tsx | 481 ++++++++++++ packages/storybook/tsconfig.json | 8 +- packages/webapp/jest.config.js | 1 + packages/webapp/pages/onboarding.tsx | 10 +- pnpm-lock.yaml | 16 + 50 files changed, 2810 insertions(+), 486 deletions(-) create mode 100644 packages/shared/__mocks__/edgeAuraMock.tsx create mode 100644 packages/shared/src/components/onboarding/useReadingReminder.ts create mode 100644 packages/shared/src/features/onboarding/shared/FunnelStepDots.tsx create mode 100644 packages/shared/src/features/onboarding/shared/FunnelStepTopBar.tsx create mode 100644 packages/shared/src/features/onboarding/shared/OnboardingBackground.tsx create mode 100644 packages/shared/src/features/onboarding/shared/useOnboardingChrome.ts create mode 100644 packages/shared/src/features/onboarding/steps/FunnelVerifyEmail.tsx create mode 100644 packages/storybook/stories/components/onboarding/SignupFunnelSteps.stories.tsx create mode 100644 packages/storybook/stories/components/onboarding/signupFunnel.mocks.tsx diff --git a/packages/extension/jest.config.js b/packages/extension/jest.config.js index 1a63a40f2a9..4fc74d78bdc 100644 --- a/packages/extension/jest.config.js +++ b/packages/extension/jest.config.js @@ -22,5 +22,6 @@ module.exports = { '\\.svg$': '/__mocks__/svgrMock.ts', '\\.css$': 'identity-obj-proxy', 'react-markdown': '/__mocks__/reactMarkdownMock.tsx', + '^edge-aura/react$': '/../shared/__mocks__/edgeAuraMock.tsx', }, }; diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index 5b73cad80ad..bca3c911786 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -17,7 +17,13 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react" + "jsx": "react", + "paths": { + // See packages/shared/tsconfig.json — this package compiles shared's + // sources under `moduleResolution: node`, which cannot follow the + // package `exports` subpath the bundler resolves at build time. + "edge-aura/react": ["../shared/node_modules/edge-aura/dist/react.d.ts"] + } }, "include": [ "custom.d.ts", diff --git a/packages/shared/__mocks__/edgeAuraMock.tsx b/packages/shared/__mocks__/edgeAuraMock.tsx new file mode 100644 index 00000000000..a6236d5c278 --- /dev/null +++ b/packages/shared/__mocks__/edgeAuraMock.tsx @@ -0,0 +1,6 @@ +/** + * `edge-aura` ships ESM only and paints into a 2D canvas context that jsdom + * does not implement. The aura is a decorative overlay with no role in any + * assertion, so tests render nothing in its place. + */ +export const EdgeAura = (): null => null; diff --git a/packages/shared/jest.config.js b/packages/shared/jest.config.js index 6f94d77408b..66ae01819d4 100644 --- a/packages/shared/jest.config.js +++ b/packages/shared/jest.config.js @@ -20,5 +20,6 @@ module.exports = { '\\.css$': 'identity-obj-proxy', 'react-markdown': '/__mocks__/reactMarkdownMock.tsx', 'react-turnstile': 'identity-obj-proxy', + '^edge-aura/react$': '/__mocks__/edgeAuraMock.tsx', }, }; diff --git a/packages/shared/package.json b/packages/shared/package.json index 0972a29239c..0f91779b139 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -127,6 +127,7 @@ "@tiptap/starter-kit": "^3.22.5", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", + "edge-aura": "^0.6.0", "emojibase-data": "^17.0.0", "fetch-event-stream": "^0.1.6", "graphql-ws": "^5.5.5", diff --git a/packages/shared/src/components/GrowthBookProvider.tsx b/packages/shared/src/components/GrowthBookProvider.tsx index 998ff7dff3a..9c30fb82f79 100644 --- a/packages/shared/src/components/GrowthBookProvider.tsx +++ b/packages/shared/src/components/GrowthBookProvider.tsx @@ -209,3 +209,7 @@ export const useFeature: GetFeatureValue = (feature) => export const useGrowthBookContext = (): GrowthBookContextValue => useContext(GrowthBookContext); + +// Re-exported so harnesses (Storybook, tests) can pin an experiment arm without +// depending on the GrowthBook package directly. +export { GrowthBookContext }; diff --git a/packages/shared/src/components/auth/AuthOptionsInner.tsx b/packages/shared/src/components/auth/AuthOptionsInner.tsx index 10146a45f2f..160495898e3 100644 --- a/packages/shared/src/components/auth/AuthOptionsInner.tsx +++ b/packages/shared/src/components/auth/AuthOptionsInner.tsx @@ -142,6 +142,7 @@ function AuthOptionsInner({ ignoreMessages = false, onboardingSignupButton, hideLoginLink, + hideSignupDisclaimer, compact, splitSignupStyle, preferGithub, @@ -856,6 +857,7 @@ function AuthOptionsInner({ className={className} onboardingSignupButton={onboardingSignupButton} hideLoginLink={hideLoginLink} + hideSignupDisclaimer={hideSignupDisclaimer} compact={compact} splitSignupStyle={splitSignupStyle} preferGithub={preferGithub} diff --git a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx index 657564e5365..b04e4c55b7f 100644 --- a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx +++ b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx @@ -36,6 +36,7 @@ interface OnboardingRegistrationFormProps extends AuthFormProps { className?: ClassName; onboardingSignupButton?: ButtonProps<'button'>; hideLoginLink?: boolean; + hideSignupDisclaimer?: boolean; compact?: boolean; splitSignupStyle?: boolean; preferGithub?: boolean; @@ -114,6 +115,7 @@ export const OnboardingRegistrationForm = ({ trigger, onboardingSignupButton, hideLoginLink, + hideSignupDisclaimer, compact, splitSignupStyle = false, preferGithub, @@ -222,7 +224,7 @@ export const OnboardingRegistrationForm = ({ ); - const disclaimer = ( + const disclaimer = hideSignupDisclaimer ? null : ( ); diff --git a/packages/shared/src/components/auth/RegistrationFieldsForm.tsx b/packages/shared/src/components/auth/RegistrationFieldsForm.tsx index 336905133b2..c0662dbeedc 100644 --- a/packages/shared/src/components/auth/RegistrationFieldsForm.tsx +++ b/packages/shared/src/components/auth/RegistrationFieldsForm.tsx @@ -13,6 +13,7 @@ import { Button, ButtonVariant } from '../buttons/Button'; import ImageInput from '../fields/ImageInput'; import { useGenerateUsername } from '../../hooks'; import { labels } from '../../lib'; +import { AtIcon, MailIcon, UserIcon } from '../icons'; export type UserExperienceLevelKey = keyof typeof UserExperienceLevel; @@ -42,6 +43,11 @@ export interface RegistrationFieldsFormProps { // Optional extra profile fields to render, driven by the onboarding funnel // (campaign cohorts). Empty/undefined renders the default fields only. extraFields?: ProfileExtraField[]; + // When set, the form renders no submit button of its own and exposes this id + // instead, so the consumer can submit it from a button rendered outside the + // form (` + {!formId && ( + + )} ); }; diff --git a/packages/shared/src/components/auth/common.tsx b/packages/shared/src/components/auth/common.tsx index b3a416e12c9..18836a8cda8 100644 --- a/packages/shared/src/components/auth/common.tsx +++ b/packages/shared/src/components/auth/common.tsx @@ -143,4 +143,6 @@ export interface AuthOptionsProps { * registration form (e.g. when the onboarding funnel already shows that copy * on the signup wall). */ hideRegistrationHeadline?: boolean; + /** Hide the "By continuing, you agree to…" strip under the signup options. */ + hideSignupDisclaimer?: boolean; } diff --git a/packages/shared/src/components/feeds/FeedPreviewControls.tsx b/packages/shared/src/components/feeds/FeedPreviewControls.tsx index 75ae4e7f478..8715498e577 100644 --- a/packages/shared/src/components/feeds/FeedPreviewControls.tsx +++ b/packages/shared/src/components/feeds/FeedPreviewControls.tsx @@ -3,13 +3,18 @@ import React from 'react'; import type { ReactElement } from 'react-markdown/lib/react-markdown'; import { Button } from '../buttons/Button'; import { ButtonVariant, ButtonIconPosition } from '../buttons/common'; -import { ArrowIcon } from '../icons'; +import { ArrowIcon, EyeCancelIcon, EyeIcon } from '../icons'; import type { Origin } from '../../lib/log'; import { LogEvent } from '../../lib/log'; import { useLogContext } from '../../contexts/LogContext'; export type FeedPreviewControlsControlsProps = { isOpen: boolean; + // The onboarding funnel renders this as one of the tag pills above it — a + // quiet reveal on the divider, the eye carrying the open/closed state — so + // it doesn't compete with the step's docked CTA. Off elsewhere, where the + // original primary button stands. + isTagStyle?: boolean; isDisabled?: boolean; textDisabled: string; origin: Origin; @@ -18,6 +23,7 @@ export type FeedPreviewControlsControlsProps = { export const FeedPreviewControls = ({ isOpen, + isTagStyle, isDisabled = false, textDisabled, origin, @@ -25,28 +31,53 @@ export const FeedPreviewControls = ({ }: FeedPreviewControlsControlsProps): ReactElement => { const { logEvent } = useLogContext(); - return ( -
-
- + const label = isDisabled + ? textDisabled + : `${isOpen ? 'Hide' : 'Show'} feed preview`; + + return ( +
+
+ {isTagStyle ? ( + // `btn-tag` plus the default size is exactly what TagElement renders, + // so the control sits in the same family as the tags it belongs to. + + ) : ( + + )}
); diff --git a/packages/shared/src/components/fields/CardCheckbox.tsx b/packages/shared/src/components/fields/CardCheckbox.tsx index 57db1da758d..fb6c85cef11 100644 --- a/packages/shared/src/components/fields/CardCheckbox.tsx +++ b/packages/shared/src/components/fields/CardCheckbox.tsx @@ -1,4 +1,4 @@ -import type { InputHTMLAttributes, ReactElement } from 'react'; +import type { ComponentType, InputHTMLAttributes, ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; import { @@ -6,7 +6,8 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { ChecklistAIcon } from '../icons'; +import { VIcon } from '../icons'; +import { IconSize } from '../Icon'; import { FunnelTargetId } from '../../features/onboarding/types/funnelEvents'; interface CustomCheckboxProps { @@ -16,25 +17,17 @@ interface CustomCheckboxProps { onCheckboxToggle: () => void; inputProps?: InputHTMLAttributes; className?: string; + // Optional: cards whose option has no icon mapped still lay out correctly. + icon?: ComponentType<{ size?: IconSize; secondary?: boolean }>; } -const border = { - checked: ` - radial-gradient( - circle at top left, - var(--theme-accent-onion-subtlest), - var(--theme-accent-cabbage-default) - ) - `, - unchecked: ` - radial-gradient( - circle at top left, - var(--theme-border-subtlest-tertiary), - var(--theme-border-subtlest-tertiary) - ) - `, -}; - +/** + * A card-sized toggle, following the giveback cause cards: the whole card is + * the target, selection is carried by a tinted fill AND a control that is + * always visible — an empty ring when off, a filled check when on. The previous + * version showed a check only when selected and nothing at all when not, so a + * card that came pre-selected was indistinguishable from a decorative panel. + */ export const CardCheckbox = ({ checked, title, @@ -42,47 +35,66 @@ export const CardCheckbox = ({ onCheckboxToggle, inputProps = {}, className, + icon: Icon, }: CustomCheckboxProps): ReactElement => { return ( -
- -
+ + + {checked && } + + ); }; diff --git a/packages/shared/src/components/fields/SearchField.tsx b/packages/shared/src/components/fields/SearchField.tsx index 771a8ff44eb..9940dc53f2d 100644 --- a/packages/shared/src/components/fields/SearchField.tsx +++ b/packages/shared/src/components/fields/SearchField.tsx @@ -43,6 +43,11 @@ export interface SearchFieldProps showIcon?: boolean; fieldType?: 'primary' | 'secondary'; rightButtonProps?: ButtonProps<'button'> | false; + /** + * Float surface instead of the opaque page background, for fields sitting on + * a gradient or image where a solid fill reads as a dark box cut into it. + */ + isFloating?: boolean; } const ButtonIcon = ({ @@ -67,6 +72,7 @@ export const SearchField = forwardRef(function SearchField( fieldSize = 'large', readOnly, fieldType = 'primary', + isFloating, className, autoFocus, type, @@ -118,8 +124,9 @@ export const SearchField = forwardRef(function SearchField( className={classNames( // Border width + background only — the resting border *color* is the Float // hairline from `.field` so the search field matches every other field. - 'items-center !border !bg-background-default', - // The base `.field:hover` background is blocked by `!bg-background-default`, + 'items-center !border', + isFloating ? '!bg-surface-float' : '!bg-background-default', + // The base `.field:hover` background is blocked by the background above, // so the search field needs its own hover feedback. Brighten the border and // tint the surface, scoped to `:not(.focused)` so it never overrides the // focus ring while the field is active. diff --git a/packages/shared/src/components/filters/helpers.ts b/packages/shared/src/components/filters/helpers.ts index f9926e2651e..a13e0cb5955 100644 --- a/packages/shared/src/components/filters/helpers.ts +++ b/packages/shared/src/components/filters/helpers.ts @@ -23,9 +23,9 @@ export const getContentCurationList = ( export const getAdvancedContentTypes = ( titles: string[], advancedSettings: AdvancedSettings[], -) => +): AdvancedSettings[] => titles .map((title) => advancedSettings?.find((advanced) => advanced.title === title), ) - .filter(Boolean); + .filter((advanced): advanced is AdvancedSettings => !!advanced); diff --git a/packages/shared/src/components/onboarding/ContentTypes/ContentTypes.tsx b/packages/shared/src/components/onboarding/ContentTypes/ContentTypes.tsx index bdd1b358eeb..a911e783201 100644 --- a/packages/shared/src/components/onboarding/ContentTypes/ContentTypes.tsx +++ b/packages/shared/src/components/onboarding/ContentTypes/ContentTypes.tsx @@ -10,6 +10,50 @@ import { } from '../../filters/helpers'; import { CardCheckbox } from '../../fields/CardCheckbox'; import { TOGGLEABLE_TYPES } from '../../feeds/FeedSettings/sections/FeedSettingsContentPreferencesSection'; +import { OnboardingHeadline } from '../common'; +import { + DiscussIcon, + DocsIcon, + HotIcon, + ImageIcon, + MegaphoneIcon, + NumberedListIcon, + PlayIcon, + PollIcon, + SquadIcon, + StoryIcon, + TLDRIcon, + TwitterIcon, +} from '../../icons'; + +/** + * Every title the step can render, from the production `advancedSettings` + * query: the `content_source` group, then `content_curation` + `source_types`, + * then the four in `TOGGLEABLE_TYPES`. Keyed by title because that is already + * how this data is matched elsewhere (`getAdvancedContentTypes` does the same), + * and an unmapped title just renders without an icon rather than breaking. + */ +const contentTypeIcon: Record = { + Comparisons: TLDRIcon, + Listicles: NumberedListIcon, + Memes: ImageIcon, + News: HotIcon, + Opinions: DiscussIcon, + Releases: MegaphoneIcon, + Squads: SquadIcon, + Stories: StoryIcon, + Tutorials: DocsIcon, + Videos: PlayIcon, + Polls: PollIcon, + Social: TwitterIcon, +}; + +/** + * Retired types. The `advancedSettings` query still returns them, so they are + * filtered here rather than assumed gone — onboarding stops offering them while + * feed settings keeps rendering whatever the API sends. + */ +const RETIRED_TITLES = ['Community picks', 'Standups']; interface ContentTypesProps { headline?: string; @@ -25,7 +69,10 @@ export const ContentTypes = ({ headline }: ContentTypesProps): ReactElement => { } = useAdvancedSettings(); const contentSourceList = useMemo( - () => getContentSourceList(advancedSettings), + () => + getContentSourceList(advancedSettings).filter( + ({ title }) => !RETIRED_TITLES.includes(title), + ), [advancedSettings], ); @@ -40,31 +87,47 @@ export const ContentTypes = ({ headline }: ContentTypesProps): ReactElement => { advancedSettings, ); - return contentCurationList.concat(listedTypes); + return contentCurationList + .concat(listedTypes) + .filter(({ title }) => !RETIRED_TITLES.includes(title)); }, [contentCurationList, advancedSettings]); - const classes = '!h-[8.25rem] max-w-80'; + // Cards fill their grid column. The old `tablet:max-w-80` was a dead class — + // `80` is not in this config's max-width scale — so it never capped anything. + const classes = 'h-full w-full'; return ( -
-

+
+ {headline || 'What kind of posts would you like to see on your feed?'} -

-
- {contentSourceList?.map(({ id, title, description, options }) => ( - onToggleSource(options.source)} - checked={!checkSourceBlocked(options.source)} - title={title} - description={description} - inputProps={{ - checked: !checkSourceBlocked(options.source), - name: `advancedSettings-${id}`, - }} - /> - ))} + + {/* Two per row from tablet up, never three: with `h-full` on the cards + every row is one height, so a two-line description no longer leaves + its neighbour looking clipped. */} +
+ {contentSourceList?.map(({ id, title, description, options }) => { + if (!options?.source) { + return null; + } + + const { source } = options; + + return ( + onToggleSource(source)} + checked={!checkSourceBlocked(source)} + title={title} + description={description} + icon={contentTypeIcon[title]} + inputProps={{ + checked: !checkSourceBlocked(source), + name: `advancedSettings-${id}`, + }} + /> + ); + })} {contentCurationAndVideoList.map( ({ id, title, description, defaultEnabledState }) => ( { checked={selectedSettings[id] ?? defaultEnabledState} title={title} description={description} + icon={contentTypeIcon[title]} inputProps={{ checked: selectedSettings[id] ?? defaultEnabledState, name: `advancedSettings-${id}`, diff --git a/packages/shared/src/components/onboarding/EditTag.tsx b/packages/shared/src/components/onboarding/EditTag.tsx index 68addf75b75..8889907cd52 100644 --- a/packages/shared/src/components/onboarding/EditTag.tsx +++ b/packages/shared/src/components/onboarding/EditTag.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from 'react'; import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { FeedPreviewControls } from '../feeds'; -import { REQUIRED_TAGS_THRESHOLD } from './common'; +import { OnboardingHeadline, REQUIRED_TAGS_THRESHOLD } from './common'; import { Origin } from '../../lib/log'; import Feed from '../Feed'; import { OtherFeedPage, RequestKey } from '../../lib/query'; @@ -27,6 +27,9 @@ interface EditTagProps { requiredTags?: number; hidePreview?: boolean; featuredTags?: string[]; + // The post-signup funnel's treatment: floating search field, tighter tag + // grid and a tag-shaped preview toggle. `/helloworld` keeps the original. + isOnboarding?: boolean; } export const EditTag = ({ feedSettings, @@ -35,6 +38,7 @@ export const EditTag = ({ requiredTags = REQUIRED_TAGS_THRESHOLD, hidePreview, featuredTags, + isOnboarding, }: EditTagProps): ReactElement => { const isMobile = useViewSize(ViewSize.MobileL); const [isPreviewVisible, setPreviewVisible] = useState(false); @@ -83,9 +87,7 @@ export const EditTag = ({ return ( <> -

- {resolvedHeadline} -

+ {resolvedHeadline} {showPersonas && ( <>

@@ -96,13 +98,26 @@ export const EditTag = ({ )}

{!hidePreview && ( -
- +
+ {headline || 'Add daily.dev to Home Screen'} - - + + Tap “Add to Home Screen” below to get daily.dev at your fingertips, anytime you need it. - +
); diff --git a/packages/shared/src/components/onboarding/ReadingReminder.tsx b/packages/shared/src/components/onboarding/ReadingReminder.tsx index bd48e72aae1..768aa56a9a7 100644 --- a/packages/shared/src/components/onboarding/ReadingReminder.tsx +++ b/packages/shared/src/components/onboarding/ReadingReminder.tsx @@ -1,152 +1,157 @@ -import type { FormEventHandler, ReactElement } from 'react'; -import React, { useContext, useEffect, useRef, useState } from 'react'; +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; import { ClickableText } from '../buttons/ClickableText'; -import { Radio } from '../fields/Radio'; -import Alert, { AlertParagraph, AlertType } from '../widgets/Alert'; -import { Button, ButtonVariant } from '../buttons/Button'; -import { usePushNotificationMutation } from '../../hooks/notifications'; -import { LogEvent, NotificationPromptSource, TargetType } from '../../lib/log'; -import { usePersonalizedDigest } from '../../hooks'; -import { UserPersonalizedDigestType } from '../../graphql/users'; +import { VIcon } from '../icons'; +import { IconSize } from '../Icon'; import { TimezoneDropdown } from '../widgets/TimezoneDropdown'; -import { useLogContext } from '../../contexts/LogContext'; -import AuthContext from '../../contexts/AuthContext'; -import { getUserInitialTimezone } from '../../lib/timezones'; import { HourDropdown } from '../fields/HourDropdown'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import type { ReadingReminderState } from './useReadingReminder'; +import { OnboardingHeadline, OnboardingSubheadline } from './common'; const ReadingReminderOptions = [ - { label: '09:00 ☕️', value: '9' }, - { label: '12:00 🥪', value: '12' }, - { label: '17:00 🛋️', value: '17' }, - { label: 'Custom️', value: 'custom' }, + { label: '09:00', hint: 'With coffee', emoji: '☕️', value: '9' }, + { label: '12:00', hint: 'Over lunch', emoji: '🥪', value: '12' }, + { label: '17:00', hint: 'Winding down', emoji: '🛋️', value: '17' }, + { label: 'Custom', hint: 'Pick a time', emoji: '⏰', value: 'custom' }, ]; interface ReadingReminderProps { - onClickNext: (options?: { skipped?: boolean }) => void; headline?: string; + // The actions live in the funnel's docked CTA rail, so the state is owned by + // the step through `useReadingReminder` and passed back in here. + state: ReadingReminderState; } export const ReadingReminder = ({ - onClickNext, headline, + state, }: ReadingReminderProps): ReactElement => { - const { user } = useContext(AuthContext); - const { logEvent } = useLogContext(); - const [loading, setLoading] = useState(false); - const [userTimeZone, setUserTimeZone] = useState( - getUserInitialTimezone({ - userTimezone: user?.timezone, - update: true, - }), - ); - const [timeOption, setTimeOption] = useState('9'); - const [customTimeIndex, setCustomTimeIndex] = useState(8); - const [isEditingTimezone, setIsEditingTimezone] = useState(false); - const isLogged = useRef(false); - const { onEnablePush } = usePushNotificationMutation(); - const { subscribePersonalizedDigest } = usePersonalizedDigest(); - - useEffect(() => { - if (!isLogged.current) { - isLogged.current = true; - logEvent({ - event_name: LogEvent.Impression, - target_type: TargetType.ReadingReminder, - }); - } - }, [logEvent]); - - const onSkip = () => { - logEvent({ - event_name: LogEvent.SkipReadingReminder, - }); - onClickNext({ skipped: true }); - }; - - const onSubmit: FormEventHandler = async () => { - if (loading) { - return; - } - setLoading(true); - const selectedHour = - timeOption === 'custom' ? customTimeIndex : parseInt(timeOption, 10); - logEvent({ - event_name: LogEvent.ScheduleReadingReminder, - extra: JSON.stringify({ - hour: selectedHour, - timezone: userTimeZone, - }), - }); - subscribePersonalizedDigest({ - hour: selectedHour, - type: UserPersonalizedDigestType.ReadingReminder, - }); - onEnablePush(NotificationPromptSource.ReadingReminder).then(() => { - onClickNext(); - setLoading(false); - }); - }; + const { + customTimeIndex, + isEditingTimezone, + setCustomTimeIndex, + setIsEditingTimezone, + setTimeOption, + setUserTimeZone, + timeOption, + userTimeZone, + } = state; return ( <> -
-

-

+ {/* gap-6, the funnel's standard headline-to-subheadline step. */} +
+ {headline || 'When do you need that reading nudge?'} -

-

- Your timezone: {userTimeZone}{' '} - {isEditingTimezone ? ( - - ) : ( - setIsEditingTimezone(true)} + + {/* The reassurance used to be a green success Alert below the options — + the wrong semantic (nothing succeeded) and, sat in a coloured box + with a tick, it read as a generated callout. As supporting copy under + the headline it does the same job in the place people already look + for context, before they choose rather than after. */} + + Devs who turn this on build a reading habit that sticks. + +

+ + {/* One per row, full width, with the selection control on the right — + the same anatomy as the content-type cards, so the funnel has a single + way of saying "pick this". A two-up grid halved the target and left + the times reading as chips rather than options. */} +
+ {ReadingReminderOptions.map(({ label, hint, emoji, value }) => { + const isSelected = timeOption === value; + + return ( + + ); + })}
- setTimeOption(value)} - /> + {timeOption === 'custom' && ( )} - - - Devs who enable notifications build a habit and become more - knowledgeable - - -
- - -
+ Times are in {userTimeZone}{' '} + {isEditingTimezone ? ( + + ) : ( + setIsEditingTimezone(true)} + > + change + + )} + ); }; diff --git a/packages/shared/src/components/onboarding/common.ts b/packages/shared/src/components/onboarding/common.ts index 7de61bf91e5..1a357dd9b98 100644 --- a/packages/shared/src/components/onboarding/common.ts +++ b/packages/shared/src/components/onboarding/common.ts @@ -20,6 +20,38 @@ export const OnboardingTitle = classed( 'text-center typo-title2 font-bold px-4', ); +/** + * The headline of a post-signup onboarding step. Every step renders this one so + * the funnel keeps a single scale, weight and alignment from step to step — + * before it, the six steps used three different sizes and disagreed on weight. + * `OnboardingTitle` above is a different, smaller heading still used by the + * extension-permission and acquisition surfaces; leave it alone. + */ +/** + * The line under an onboarding headline. One size and colour across every step + * — the steps used to disagree (Title3 on the extension and CV steps, Body on + * the PWA one), which read as three different levels of importance for the same + * kind of copy. Shares the headline's 440px measure so both wrap alike. + */ +export const OnboardingSubheadline = classed( + 'p', + // `[text-wrap:pretty]`, never `text-balance`: balance equalises line lengths + // and leaves every line short of the 440px measure, which reads as manual + // line breaks. Pretty only pulls up a lone trailing word. + 'mx-auto w-full max-w-[27.5rem] text-center text-text-secondary typo-body [text-wrap:pretty]', +); + +export const OnboardingHeadline = classed( + 'h1', + // 27.5rem is the 440px the CTA rail caps at. Capping the headline to the same + // measure means the title wraps identically on every step, even the ones + // whose content below spreads much wider. + // The color must be the explicit token, not inherited: this repo's `.invert` + // flips a subtree's theme by swapping CSS variables, so on the forced-dark + // steps an inherited color sails through the flip and stays light-theme dark. + 'mx-auto w-full max-w-[27.5rem] text-center font-bold text-text-primary typo-title1', +); + export const onboardingGradientClasses = 'font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-bacon-default to-accent-cabbage-default'; export const OnboardingTitleGradient = classed('h1', onboardingGradientClasses); diff --git a/packages/shared/src/components/onboarding/useReadingReminder.ts b/packages/shared/src/components/onboarding/useReadingReminder.ts new file mode 100644 index 00000000000..8d93d19a45c --- /dev/null +++ b/packages/shared/src/components/onboarding/useReadingReminder.ts @@ -0,0 +1,89 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { usePushNotificationMutation } from '../../hooks/notifications'; +import { LogEvent, NotificationPromptSource, TargetType } from '../../lib/log'; +import { usePersonalizedDigest } from '../../hooks'; +import { UserPersonalizedDigestType } from '../../graphql/users'; +import { useLogContext } from '../../contexts/LogContext'; +import AuthContext from '../../contexts/AuthContext'; +import { getUserInitialTimezone } from '../../lib/timezones'; + +interface UseReadingReminderProps { + onClickNext: (options?: { skipped?: boolean }) => void; +} + +export const useReadingReminder = ({ + onClickNext, +}: UseReadingReminderProps) => { + const { user } = useContext(AuthContext); + const { logEvent } = useLogContext(); + const [loading, setLoading] = useState(false); + const [userTimeZone, setUserTimeZone] = useState( + getUserInitialTimezone({ + userTimezone: user?.timezone, + update: true, + }), + ); + const [timeOption, setTimeOption] = useState('9'); + const [customTimeIndex, setCustomTimeIndex] = useState(8); + const [isEditingTimezone, setIsEditingTimezone] = useState(false); + const isLogged = useRef(false); + const { onEnablePush } = usePushNotificationMutation(); + const { subscribePersonalizedDigest } = usePersonalizedDigest(); + + useEffect(() => { + if (!isLogged.current) { + isLogged.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.ReadingReminder, + }); + } + }, [logEvent]); + + const onSkip = () => { + logEvent({ + event_name: LogEvent.SkipReadingReminder, + }); + onClickNext({ skipped: true }); + }; + + const onSubmit = async () => { + if (loading) { + return; + } + setLoading(true); + const selectedHour = + timeOption === 'custom' ? customTimeIndex : parseInt(timeOption, 10); + logEvent({ + event_name: LogEvent.ScheduleReadingReminder, + extra: JSON.stringify({ + hour: selectedHour, + timezone: userTimeZone, + }), + }); + subscribePersonalizedDigest({ + hour: selectedHour, + type: UserPersonalizedDigestType.ReadingReminder, + }); + onEnablePush(NotificationPromptSource.ReadingReminder).then(() => { + onClickNext(); + setLoading(false); + }); + }; + + return { + customTimeIndex, + isEditingTimezone, + loading, + onSkip, + onSubmit, + setCustomTimeIndex, + setIsEditingTimezone, + setTimeOption, + setUserTimeZone, + timeOption, + userTimeZone, + }; +}; + +export type ReadingReminderState = ReturnType; diff --git a/packages/shared/src/components/plus/PlusListItem.tsx b/packages/shared/src/components/plus/PlusListItem.tsx index ac508f2ce31..f8cb853768d 100644 --- a/packages/shared/src/components/plus/PlusListItem.tsx +++ b/packages/shared/src/components/plus/PlusListItem.tsx @@ -57,9 +57,14 @@ export const PlusListItem = ({ return ( ( i); const PlusSkeleton = (): ReactElement => ( @@ -45,40 +49,73 @@ const PlusSkeleton = (): ReactElement => ( type Parameters = Pick; interface OnboardingPlusControlProps extends Parameters { - onSkip?: () => void; - onComplete?: () => void; + onSkip: () => void; + onComplete: () => void; + /** Onboarding-only: the shared funnel header. The paid funnel keeps its own. */ + isOnboarding?: boolean; } export const OnboardingPlusControl = ({ parameters: { headline, explainer, free, plus }, onSkip, onComplete, + isOnboarding, }: OnboardingPlusControlProps): ReactElement => { const isLaptop = useViewSize(ViewSize.Laptop); const { item } = useFunnelAnnualPricing(); return ( -
+
- - {headline || 'Fast-track your growth'} - - - {explainer || - `Work smarter, learn faster, and stay ahead with AI tools, custom - feeds, and pro features. Because copy-pasting code isn't a - long-term strategy.`} - + {isOnboarding ? ( + <> + {/* The shared onboarding type: one size on every step and every + breakpoint, on the same 440px measure. The paid funnel's own + header below scales itself by viewport instead. */} + + {headline || 'Fast-track your growth'} + + + {explainer || + `Work smarter, learn faster, and stay ahead with AI tools, custom + feeds, and pro features. Because copy-pasting code isn't a + long-term strategy.`} + + + ) : ( + <> + + {headline || 'Fast-track your growth'} + + + {explainer || + `Work smarter, learn faster, and stay ahead with AI tools, custom + feeds, and pro features. Because copy-pasting code isn't a + long-term strategy.`} + + + )}
{item ? ( { return ( -
- - {headline} - +
+ {/* Only the title pair follows the funnel's shared type scale — the rest + of the step is production's markup unchanged. */} + {headline} + {description} - - {description} - -
+
{linkedin.headline} @@ -118,7 +110,7 @@ export const UploadCv = ({ {linkedin.headline}
diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx index 075320d0962..151a92e5749 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx @@ -1,13 +1,21 @@ -import type { ReactElement, ComponentProps } from 'react'; -import React, { useMemo } from 'react'; +import type { CSSProperties, ReactElement, ComponentProps } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; +import { EdgeAura } from 'edge-aura/react'; +import type { EdgeAuraOptions } from 'edge-aura'; import type { FunnelStep } from '../types/funnel'; import { FunnelStepType, FunnelBackgroundVariant } from '../types/funnel'; import { useIsLightTheme } from '../../../hooks/utils'; +import { useViewSize, ViewSize } from '../../../hooks'; import { isFunnelPricingV2 } from '../steps/FunnelPricing/common'; +import { OnboardingBackground } from './OnboardingBackground'; +import { useOnboardingChrome } from './useOnboardingChrome'; interface StepBackgroundProps extends ComponentProps<'div'> { step: FunnelStep; + // Only the post-signup onboarding funnel gets the aura; `/helloworld` renders + // the same step types with their original gradients. + isOnboarding?: boolean; } const variantToClassName: Record = { @@ -27,6 +35,48 @@ const variantToClassName: Record = { [FunnelBackgroundVariant.Burger]: 'bg-accent-burger-flat', }; +// Above the funnel's own layers (content is z-2, the docked CTA z-3) so the +// glow spills over the UI at the edges the way a screen-edge light does. +const AURA_STYLE: CSSProperties = { zIndex: 4 }; + +// How long the ring holds the violet palette before crossfading back. Short +// enough that the flush reads as a reaction to the tap rather than a mood the +// step settles into — the engine's own 350ms crossfade runs at each end of it. +const PULSE_HOLD_MS = 620; + +/** + * The pulse colour. Not the engine's stock `ultraviolet` — that swaps the whole + * ring for a different mood and reads as a jump cut. These stops keep opal's + * shape and lean it into lavender, so the 350ms crossfade lands as the same + * ring flushing purple and settling back. + */ +const PULSE_PALETTE: Array<[number, [number, number, number]]> = [ + [0, [124, 100, 232]], + [0.2, [156, 118, 250]], + [0.42, [198, 158, 255]], + [0.6, [172, 128, 248]], + [0.82, [128, 104, 226]], + [1, [124, 100, 232]], +]; + +// The post-signup onboarding steps. They drop the funnel's per-step purple wash +// for one canvas across the whole flow, so it reads as a single surface: the +// brand gradient by default, the edge aura on the experiment arm. +const onboardingSteps = [ + FunnelStepType.ProfileForm, + FunnelStepType.EditTags, + FunnelStepType.ContentTypes, + FunnelStepType.ReadingReminder, + FunnelStepType.InstallPwa, + FunnelStepType.BrowserExtension, + // Also rendered by the paid funnel — the `isOnboarding` gate below keeps + // this treatment out of `/helloworld`. + FunnelStepType.Pricing, + FunnelStepType.UploadCv, + FunnelStepType.PlusCards, + FunnelStepType.VerifyEmail, +]; + const getVariantFromStep = (step: FunnelStep): FunnelBackgroundVariant => { if (!step) { return FunnelBackgroundVariant.Default; @@ -63,12 +113,11 @@ const alwaysDarkSteps = [ FunnelStepType.HeroLanding, FunnelStepType.BrowserExtension, ]; -const tallTopGradientSteps = [FunnelStepType.EditTags]; - export const FunnelStepBackground = ({ children, className, step, + isOnboarding, }: StepBackgroundProps): ReactElement => { const isLightMode = useIsLightTheme(); const isPricingV2 = @@ -76,10 +125,15 @@ export const FunnelStepBackground = ({ const isStepForcedTo = useMemo( () => ({ - dark: alwaysDarkSteps.includes(step.type), + // Install PWA's footage (the iOS share sheet) only exists dark, so in the + // onboarding funnel the step forces a dark surface the way the extension + // step always has. Onboarding-only: the paid funnel keeps its behavior. + dark: + alwaysDarkSteps.includes(step.type) || + (!!isOnboarding && step.type === FunnelStepType.InstallPwa), light: isPricingV2, }), - [isPricingV2, step.type], + [isOnboarding, isPricingV2, step.type], ); const bgClassName = useMemo(() => { @@ -90,9 +144,99 @@ export const FunnelStepBackground = ({ ); }, [step]); + const isOnboardingStep = + !!isOnboarding && onboardingSteps.includes(step.type); + // Aura and dots are one experiment arm; the gradient canvas is the control. + const { hasAura: isAuraArm } = useOnboardingChrome(isOnboarding); + const hasAura = isOnboardingStep && isAuraArm; + const isMobile = useViewSize(ViewSize.MobileXL); + // Every step change bumps this, and each change fires one ambient swell in + // the ring — the funnel reacting to the user moving forward rather than + // sitting there. The engine only needs the value to change; the first bump + // off 0 pulses as the funnel opens. + const [stepPulse, setStepPulse] = useState(0); + // The pulse recolours the RING: the engine crossfades palettes over 350ms, so + // swapping to the violet set and back reads as the gradient itself flushing + // purple. A tinted overlay on top looked like the page background changing. + const [isPulsing, setIsPulsing] = useState(false); + useEffect(() => { + setStepPulse((count) => count + 1); + setIsPulsing(true); + const timeout = setTimeout(() => setIsPulsing(false), PULSE_HOLD_MS); + + return () => clearTimeout(timeout); + }, [step.id]); + const auraOptions = useMemo( + () => ({ + geometry: { + // `band` is the depth of the inward dissolve, not the width of the + // bright edge — the core line is a separate, absolute thickness. A deep + // band with a thin core is what reads as light bleeding into the page + // (Apple Intelligence) rather than a frame drawn around it; a shallow + // band puts the whole falloff in a few pixels and looks like a border. + band: isMobile ? 44 : 76, + // The waves are the ring's undulation, not its size: `band` and the + // `innerSoftBase` average stay put, and only the VARIANCE grows. Deeper + // troughs and fatter crests make the dark scallops read as a shape + // travelling around the ring rather than a even band. Core base stays + // above ~1.0σ at the thin end, below which the engine warns of + // 1px-grid shimmer. + coreSigmaBase: 1.8, + coreSigmaVar: 0.8, + innerSoftBase: 1.25, + innerSoftVar: 0.95, + // Without this the ring's rounded corner leaves the square screen + // corner as a dark pocket outside the arc. `cornerFill` renders that + // pocket as the union of the two adjacent bands — square on the outside + // against the screen, still rounded on the inside. + cornerFill: true, + // Softer inner bend than the stock 11px. With `cornerFill` on, a larger + // radius also shrinks the outward pocket it has to fill. + cornerRadius: 28, + }, + motion: { + // Three cycles on deliberately non-divisible periods — rotation 13s, + // hue sway 17s, highlight lap 19s. Nothing shares a factor, so the + // crests drift in and out of phase and the ring never repeats a shape. + // (Aurora gradients read as organic for the same reason: layers on + // co-prime durations that occasionally overlap into a hot spot.) + rotateIdleS: 13, + // Faster energy decay (default 1.1/s) so the swell snaps back instead + // of lingering — a quick breath, not a slow fade. + decay: 2.1, + hueDriftDeg: 26, + hueDriftPeriodS: 17, + // `min` is the bloom left OUTSIDE the crest. Dropping it far below the + // default is what turns a uniform band into one dominant travelling + // wave with quiet stretches either side. + highlight: { arcDeg: 100, periodS: 19, min: 0.22 }, + }, + input: { + // The step-change swell is the engine's own amplitude reaction — real + // physics rather than a CSS filter, so it blooms outward instead of + // washing the ring white. Pushed near the 1.5 saturation cap so the + // bloom is unmistakable on tap. + savedPulseEnergy: 1.25, + }, + palette: { + // Palette weight is measured as distance from the page. On a dark + // background the engine also lifts the faint tail and pre-lifts chroma, + // which is what gives the ring its body on black — and it defaults to + // "light", so it has to follow the funnel's theme. + background: isLightMode ? 'light' : 'dark', + // Quieter than the stock 0.90/0.35: this is a background for a form, + // not the centrepiece, so the hues stay milky and the core stops short + // of full strength. + ringAlpha: 0.7, + pastel: 0.5, + }, + }), + [isLightMode, isMobile], + ); const shouldShowBg = - !isPricingV2 && !hiddenBgSteps.some((type) => type === step.type); - const shouldUseTallTopGradient = tallTopGradientSteps.includes(step.type); + !isPricingV2 && + !isOnboardingStep && + !hiddenBgSteps.some((type) => type === step.type); const needInvertedColors = (isStepForcedTo.dark && isLightMode) || @@ -106,13 +250,25 @@ export const FunnelStepBackground = ({ )} >
{children}
+ {/* The aura is a light source, not a backdrop: it sits above the content + like the reference does, so the ring reads as the screen's own edge + glowing rather than a gradient the page is printed on. The engine + renders a fixed, click-through canvas and handles reduced-motion, + hidden tabs and its own idle frame-rate throttle. */} + {hasAura && ( + + )} + {isOnboardingStep && !hasAura && } {shouldShowBg && (
& { - cta?: { - label?: string; - note?: string; - animation?: string; +export type FunnelStepCtaWrapperProps = ButtonProps<'button'> & + // Some steps link out instead of transitioning (e.g. the extension download), + // so the docked CTA has to be able to render as an anchor. + Pick, 'href' | 'target' | 'rel'> & { + cta?: { + label?: string; + note?: string; + animation?: string; + }; + containerClassName?: string; + skip?: ButtonProps<'button'> & { + cta?: string; + }; + // Opt-in: renders the docked CTA as the floating glass bar. Only the + // post-signup onboarding steps use it — the paid funnel keeps the original + // full-width button below. + isGlass?: boolean; + // Rendered in the sticky rail directly above the bar, so a control the CTA + // acts on (the Plus/Free choice) travels with it instead of scrolling away. + docked?: ReactNode; }; - containerClassName?: string; - skip?: ButtonProps<'button'> & { - cta?: string; - }; -}; + +/** + * The rail the docked CTA bar sits in. Steps put their content column in the + * same rail so the bar shares the content's edges and keeps an identical width + * and position from step to step. + * + * 32rem = the 440px the rail is allowed to grow to, plus the px-6 on each side, + * so the content inside the rail measures exactly 440px once the cap binds and + * stays 24px clear of the screen edges below that — enough to keep the content + * out of the edge aura's glow. + */ +export const funnelStepRail = 'mx-auto w-full max-w-[32rem] px-6'; export function FunnelStepCtaWrapper({ children, @@ -35,8 +59,11 @@ export function FunnelStepCtaWrapper({ cta, skip, containerClassName, + isGlass, + docked, ...props }: FunnelStepCtaWrapperProps): ReactElement { + const { cta: skipLabel, ...skipProps } = skip ?? {}; const note = useMemo(() => { if (!cta?.note) { return null; @@ -54,33 +81,100 @@ export function FunnelStepCtaWrapper({ ); }, [cta?.note]); - return ( -
-
{children}
-
- {note} - - {skip && ( + if (!isGlass) { + return ( +
+
+ {children} +
+
+ {note} - )} + {skip && ( + + )} +
+
+ ); + } + + return ( +
+ +
{children}
+ {/* A floating glass control bar rather than a button sitting on the page: + it hovers above the content, blurred and shadowed, and the CTA flexes + to fill it, so the bar is one width on every step. */} +
+ {/* A scrim under the docked controls: content scrolling past the bar + dissolves into the page instead of colliding with it. Full-bleed, so + it sits outside the rail, and it runs from below the screen edge (to + cover the safe-area offset) up to just above the dots. */} +
+
+ {note} + {!!docked && ( + // The rail is click-through so the glow can sit over content; a + // docked control has to opt back in. +
{docked}
+ )} + {/* Nested-radius rule: the inner button radius (Medium = rounded-12) + plus the bar's p-1.5 (6px) = rounded-18, so the curves stay + concentric. */} + {/* `bg-surface-float` + a heavy blur is the design system's glass (see + MobilePostFloatingBar). A `bg-background-default/95` reads as + translucent but resolves to transparent — the slash modifier can't + apply an alpha to these CSS-variable colours. + The shadow is a soft ambient wash rather than the `shadow-2` drop: + no offset, a wide blur and the lightest shadow tint, so the bar + reads as lifted without a hard edge under it. */} +
+ {/* Skip lives in the top bar, so the CTA owns the whole bar. */} + +
+ {/* Under the bar, not above it: the CTA is what the eye should land + on, and the progress reads as a footnote to it. */} + +
); diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepDots.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepDots.tsx new file mode 100644 index 00000000000..34cf7bfb33e --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/FunnelStepDots.tsx @@ -0,0 +1,88 @@ +import type { ReactElement } from 'react'; +import React, { createContext, useContext } from 'react'; +import classNames from 'classnames'; +import type { FunnelPosition } from '../types/funnel'; +import { useOnboardingChrome } from './useOnboardingChrome'; + +export interface FunnelProgressValue { + chapters: { steps: number }[]; + position: FunnelPosition; + /** + * True only inside the post-signup onboarding funnel (`/onboarding`). Steps + * shared with the paid funnel (`/helloworld`) read this to opt into the + * onboarding treatment — docked glass CTA, shared headline, 440px rail — + * without changing how they look in the paid flow. + */ + isOnboarding?: boolean; +} + +/** + * The stepper owns the funnel's position, but the dots live at the bottom of + * the docked CTA — several layers down, inside components that receive only + * their own step. The default `null` means "no funnel around me", so a step + * rendered on its own (tests, Storybook) simply shows no dots. + */ +export const FunnelProgressContext = createContext( + null, +); + +export const useIsOnboardingFunnel = (): boolean => + !!useContext(FunnelProgressContext)?.isOnboarding; + +/** + * One dot per step of the current chapter, filled up to the step the user is + * on — how far through the flow they are, and how much is left. + * + * Part of the same experiment arm as the edge aura (see `useOnboardingChrome`), + * so the control funnel shows the CTA with nothing under it. + */ +export function FunnelStepDots({ + className, +}: { + className?: string; +}): ReactElement | null { + const progress = useContext(FunnelProgressContext); + const { hasDots } = useOnboardingChrome(progress?.isOnboarding); + const total = progress?.chapters?.[progress.position.chapter]?.steps ?? 0; + + // A single dot communicates nothing. + if (!progress || !hasDots || total < 2) { + return null; + } + + const { step: currentIndex } = progress.position; + + return ( +
+ {Array.from({ length: total }, (_, index) => { + const isCurrent = index === currentIndex; + const isVisited = index < currentIndex; + + return ( + + ); + })} +
+ ); +} diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.tsx new file mode 100644 index 00000000000..5fcf8f515f3 --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { ButtonProps } from '../../../components/buttons/Button'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import Logo, { LogoPosition } from '../../../components/Logo'; +import { FunnelTargetId } from '../types/funnelEvents'; + +export interface FunnelStepTopBarProps { + skip?: ButtonProps<'button'> & { cta?: string }; +} + +/** + * The onboarding funnel's chrome: brand on the left, the way out on the right. + * + * Shared rather than owned by the CTA wrapper, because the Plus step keeps + * production's own layout (its actions live inside the plan cards) and still + * needs the same strip above it. + */ +export function FunnelStepTopBar({ + skip, +}: FunnelStepTopBarProps): ReactElement { + const { cta: skipLabel, ...skipProps } = skip ?? {}; + + return ( + // In flow, not absolute, so the strip reserves its own height and can never + // land on a step's headline; sticky so it stays reachable on long steps. +
+ {/* Full-bleed, not on the rail: the rail is a 512px centred box, so a logo + inside it reads as centred on a wide screen instead of sitting in the + corner. `px-6` matches the rail's gutter, so on a phone the logo still + lines up with the content below it, and the strip's padding is an even + 24px from the top and both edges. */} + {/* `h-8` = the skip button's height, applied whether or not a step has a + skip, so the logo sits at the same y on every step. */} +
+ {/* Icon only below laptop: the wordmark costs width the skip button + needs on a 390px screen. */} + + {skip && ( + + )} +
+
+ ); +} diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index 660895b5420..2462ae0313a 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -35,11 +35,13 @@ import { FunnelHeroLanding, FunnelBrowserExtension, FunnelUploadCv, + FunnelVerifyEmail, } from '../steps'; import { FunnelFact } from '../steps/FunnelFact'; import { FunnelCheckout } from '../steps/FunnelCheckout'; import FunnelLoading from '../steps/FunnelLoading'; import { FunnelStepBackground } from './FunnelStepBackground'; +import { FunnelProgressContext } from './FunnelStepDots'; import { useStepTransition } from '../hooks/useStepTransition'; import { FunnelRegistration } from '../steps/FunnelRegistration'; import type { FunnelSession } from '../types/funnelBoot'; @@ -59,6 +61,9 @@ export interface FunnelStepperProps { showCookieBanner?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- step types have heterogeneous props and are selected by step.type at runtime stepComponentOverrides?: Partial>>; + // Set by `/onboarding`. Steps shared with the paid funnel use it to opt into + // the onboarding treatment; `/helloworld` leaves it off and is unchanged. + isOnboarding?: boolean; } const stepComponentMap = { @@ -81,6 +86,7 @@ const stepComponentMap = { [FunnelStepType.PlusCards]: FunnelPlusCards, [FunnelStepType.BrowserExtension]: FunnelBrowserExtension, [FunnelStepType.UploadCv]: FunnelUploadCv, + [FunnelStepType.VerifyEmail]: FunnelVerifyEmail, } as const; function FunnelStepComponent(props: { @@ -110,6 +116,7 @@ export const FunnelStepper = ({ showCookieBanner, onComplete, stepComponentOverrides, + isOnboarding, }: FunnelStepperProps): ReactElement | null => { const steps = useMemo( () => funnel?.chapters?.flatMap((chapter) => chapter?.steps), @@ -142,6 +149,13 @@ export const FunnelStepper = ({ }); const shouldSkipRef = useRef>>({}); + // Feeds the dots above the docked CTA; the stepper is the only place that + // knows how far through the funnel the user is. + const funnelProgress = useMemo( + () => ({ chapters, position, isOnboarding }), + [chapters, position, isOnboarding], + ); + const currentNavigationRef = useRef({ step, position }); currentNavigationRef.current = { step, position }; @@ -218,9 +232,13 @@ export const FunnelStepper = ({ const hasBanner = !!funnel?.parameters?.banner?.stepsToDisplay?.includes( step.id, ); + // Onboarding carries its own chrome in the step's CTA wrapper — logo left, + // skip right, on every step. Rendering this header too would put a second + // logo and a second skip on the three steps that ask for it. const hasHeader = - step.parameters.shouldShowHeader || - stepsWithHeader.some((type) => type === step.type); + !isOnboarding && + (step.parameters.shouldShowHeader || + stepsWithHeader.some((type) => type === step.type)); const hasCookieConsent = isCookieBannerActive && showBanner; const isFullWidth = stepsFullWidth.includes(step.type); @@ -232,6 +250,7 @@ export const FunnelStepper = ({ }; }, [ isCookieBannerActive, + isOnboarding, showBanner, step.id, step.type, @@ -264,62 +283,64 @@ export const FunnelStepper = ({ {layout.hasCookieConsent && ( )} - -
- {layout.hasBanner && funnel.parameters.banner && ( - - )} -
{ - onTransition({ type: FunnelStepTransitionType.Skip }); - }} - isSkipDisabled={isTransitioning} - showBackButton={back.hasTarget && !hasOnlySkipButton} - showSkipButton={shouldShowHeaderSkip} - showProgressBar={!hasOnlySkipButton} - /> - - - {steps?.map((funnelStep: FunnelStep) => { - const isActive = funnelStep?.id === step?.id; - return ( -
- -
- ); + + +
+ {layout.hasBanner && funnel.parameters.banner && ( + + )} +
- -
+ currentChapter={position.chapter} + currentStep={position.step} + onBack={back.navigate} + onSkip={() => { + onTransition({ type: FunnelStepTransitionType.Skip }); + }} + isSkipDisabled={isTransitioning} + showBackButton={back.hasTarget && !hasOnlySkipButton} + showSkipButton={shouldShowHeaderSkip} + showProgressBar={!hasOnlySkipButton} + /> + + + {steps?.map((funnelStep: FunnelStep) => { + const isActive = funnelStep?.id === step?.id; + return ( +
+ +
+ ); + })} +
+
+
+
); diff --git a/packages/shared/src/features/onboarding/shared/OnboardingBackground.tsx b/packages/shared/src/features/onboarding/shared/OnboardingBackground.tsx new file mode 100644 index 00000000000..9a8b857537d --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/OnboardingBackground.tsx @@ -0,0 +1,52 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React from 'react'; + +// The giveback funnel's canvas, reused so the two full-screen flows read as the +// same product: the signature pink → purple → blue sweep (cabbage → onion → +// blueCheese) glowing from the top of a dark surface, a wide horizon glow at the +// bottom for depth, and a whisper of grain so the gradient does not band. Every +// tint is a theme token via color-mix, so it tracks the design system. +// +// The sweep fades only vertically (a top-anchored linear mask) so it fills the +// full width edge to edge, including the top corners. A radial mask centred at +// the top looks prettier but starves the corners, which reads as the gradient +// being cut off. +const brandSweep: CSSProperties = { + backgroundImage: + 'linear-gradient(125deg, ' + + 'color-mix(in srgb, var(--theme-accent-cabbage-default) 34%, transparent), ' + + 'color-mix(in srgb, var(--theme-accent-onion-default) 34%, transparent) 38%, ' + + 'color-mix(in srgb, var(--theme-accent-blueCheese-default) 30%, transparent) 62%, ' + + 'color-mix(in srgb, var(--theme-accent-onion-default) 34%, transparent) 82%, ' + + 'color-mix(in srgb, var(--theme-accent-cabbage-default) 34%, transparent))', + maskImage: 'linear-gradient(to bottom, black 0%, black 32%, transparent 92%)', + WebkitMaskImage: + 'linear-gradient(to bottom, black 0%, black 32%, transparent 92%)', +}; + +const horizonGlow: CSSProperties = { + background: + 'radial-gradient(120% 38% at 50% 102%, color-mix(in srgb, var(--theme-accent-onion-default) 16%, transparent), transparent 72%)', +}; + +const grain: CSSProperties = { + backgroundImage: + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")", +}; + +const vignette: CSSProperties = { + background: + 'radial-gradient(125% 80% at 50% 24%, transparent 52%, color-mix(in srgb, var(--theme-background-default) 80%, transparent) 100%)', +}; + +export const OnboardingBackground = (): ReactElement => ( +
+ {/* A fixed-height hero band anchored to the top rather than `inset-0`: the + mask would otherwise scale with page height and the glow would spread + down the long steps. */} +
+
+
+
+
+); diff --git a/packages/shared/src/features/onboarding/shared/useOnboardingChrome.ts b/packages/shared/src/features/onboarding/shared/useOnboardingChrome.ts new file mode 100644 index 00000000000..4fab541449c --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/useOnboardingChrome.ts @@ -0,0 +1,33 @@ +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { + featureOnboardingChrome, + OnboardingChromeVariant, +} from '../../../lib/featureManagement'; + +interface OnboardingChrome { + /** The animated edge-aura frame around the viewport. */ + hasAura: boolean; + /** The progress dots under the docked CTA. */ + hasDots: boolean; +} + +/** + * The two decorative pieces of the onboarding funnel — the aura frame and the + * progress dots — ship together as one experiment arm, so they are read from a + * single flag rather than each component deciding for itself. + * + * The control arm is the brand gradient canvas with no dots. Everything else in + * the redesign (the rail, the type scale, the glass CTA, the top strip) is + * baseline and is not gated here. + */ +export const useOnboardingChrome = ( + isOnboarding?: boolean, +): OnboardingChrome => { + const { value } = useConditionalFeature({ + feature: featureOnboardingChrome, + shouldEvaluate: !!isOnboarding, + }); + const isAura = !!isOnboarding && value === OnboardingChromeVariant.Aura; + + return { hasAura: isAura, hasDots: isAura }; +}; diff --git a/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx b/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx index 55444fcc8f1..2e558802814 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useEffect } from 'react'; +import classNames from 'classnames'; import { useRouter } from 'next/router'; import type { FunnelStepBrowserExtension } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; @@ -17,23 +18,27 @@ import { TypographyTag, TypographyType, } from '../../../components/typography/Typography'; -import { Button } from '../../../components/buttons/Button'; import { downloadBrowserExtension } from '../../../lib/constants'; import { ChromeIcon, EdgeIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; import { LogEvent, TargetType } from '../../../lib/log'; import { anchorDefaultRel } from '../../../lib/strings'; -import { ButtonVariant } from '../../../components/buttons/common'; import { FunnelTargetId } from '../types/funnelEvents'; import { withIsActiveGuard } from '../shared/withActiveGuard'; import { sanitizeMessage } from '../lib/utils'; import { withShouldSkipStepGuard } from '../shared/withShouldSkipStepGuard'; import { PlusTrustReviews } from '../../../components/plus/PlusTrustReviews'; +import { + OnboardingHeadline, + OnboardingSubheadline, +} from '../../../components/onboarding/common'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; const BROWSER_EXTENSION_DEFAULTS = { headline: 'Transform every new tab into a learning powerhouse', explainer: 'Unlock the power of every new tab with daily.dev extension. Personalized feed, developer communities, AI search and more!', - cta: 'Get it for {browser}', + cta: 'Add to {browser}', skip: 'Dare to skip? You might miss out.', showReviews: false, }; @@ -69,48 +74,55 @@ const BrowserExtension = ({ }, [applyThemeMode]); return ( -
-
- + ) : ( + + ) + } + onClick={() => { + logEvent({ + event_name: LogEvent.DownloadExtension, + target_id: isEdge ? TargetType.Edge : TargetType.Chrome, + }); + onTransition?.({ + type: FunnelStepTransitionType.Complete, + details: { browserName }, + }); + }} + rel={anchorDefaultRel} + skip={{ + cta: 'Skip', + onClick: () => onTransition?.({ type: FunnelStepTransitionType.Skip }), + }} + tag="a" + target="_blank" + containerClassName="flex flex-col" + > +
+ - - {showReviews && (
@@ -123,16 +135,19 @@ const BrowserExtension = ({
)} + {/* An aside under the subtitle, so a step quieter than it. */}
-
+ {/* Capped at 640px: at 48rem the footage dominated the step and pushed + the copy off the fold. */} +
-
+ ); }; diff --git a/packages/shared/src/features/onboarding/steps/FunnelContentTypes.tsx b/packages/shared/src/features/onboarding/steps/FunnelContentTypes.tsx index 3fa98d2267e..b0e0ab0b98e 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelContentTypes.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelContentTypes.tsx @@ -1,9 +1,10 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; +import classNames from 'classnames'; import type { FunnelStepContentTypes } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import { ContentTypes } from '../../../components/onboarding'; -import { FunnelStepCtaWrapper } from '../shared'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; import { withIsActiveGuard } from '../shared/withActiveGuard'; import { useAuthContext } from '../../../contexts/AuthContext'; import { getContentTypeNotEmpty } from '../../../components/onboarding/ContentTypes/helpers'; @@ -34,12 +35,20 @@ function FunnelContentTypesComponent({ return ( -
+ {/* single-column on mobile, so the cards sit on the CTA rail; from tablet + up the grid needs more room than the rail and stays centered instead */} +
diff --git a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx index 77af111c0ad..a7434b3faeb 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx @@ -5,15 +5,17 @@ import type { FunnelStepEditTags } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import { EditTag } from '../../../components/onboarding'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { FunnelStepCtaWrapper } from '../shared'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; import useFeedSettings from '../../../hooks/useFeedSettings'; import { withIsActiveGuard } from '../shared/withActiveGuard'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; function FunnelEditTagsComponent({ parameters: { headline, cta, minimumRequirement, featuredTags }, onTransition, }: FunnelStepEditTags): ReactElement | null { const { feedSettings } = useFeedSettings(); + const isOnboarding = useIsOnboardingFunnel(); const { user, trackingId } = useAuthContext(); const handleComplete = () => { onTransition({ @@ -32,18 +34,25 @@ function FunnelEditTagsComponent({ return ( -
+
onTransition({ type: FunnelStepTransitionType.Complete })} > -
+
diff --git a/packages/shared/src/features/onboarding/steps/FunnelPlusCards.tsx b/packages/shared/src/features/onboarding/steps/FunnelPlusCards.tsx index d86d1215954..90ed63f725c 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelPlusCards.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelPlusCards.tsx @@ -5,10 +5,15 @@ import { withIsActiveGuard } from '../shared/withActiveGuard'; import { useAuthContext } from '../../../contexts/AuthContext'; import { OnboardingPlusControl } from '../components/OnboardingPlusControl'; import { OnboardingPlusVariationV1 } from '../components/OnboardingPlusVariationV1'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; +import { FunnelStepTopBar } from '../shared/FunnelStepTopBar'; const PlusCards = ({ onTransition, parameters }: FunnelStepPlusCards) => { const { user } = useAuthContext(); const { version = 'V1' } = parameters; + // Only the header type follows the funnel's shared scale; the paid funnel + // keeps its own. + const isOnboarding = useIsOnboardingFunnel(); const transitionToNext = useCallback( ({ skip }: { skip: boolean }) => { @@ -42,7 +47,18 @@ const PlusCards = ({ onTransition, parameters }: FunnelStepPlusCards) => { case 'v2': return ; default: - return ; + // The Plus step keeps production's layout, so it has no CTA wrapper to + // carry the strip — it renders it itself. + return isOnboarding ? ( +
+ transitionToNext({ skip: true }) }} + /> + +
+ ) : ( + + ); } }; diff --git a/packages/shared/src/features/onboarding/steps/FunnelProfileForm.tsx b/packages/shared/src/features/onboarding/steps/FunnelProfileForm.tsx index 172f803aeff..eb8dbfcebee 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelProfileForm.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelProfileForm.tsx @@ -1,17 +1,21 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import classNames from 'classnames'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { - Typography, - TypographyType, -} from '../../../components/typography/Typography'; +import { OnboardingHeadline } from '../../../components/onboarding/common'; import type { RegistrationFieldsFormValues } from '../../../components/auth/RegistrationFieldsForm'; import RegistrationFieldsForm from '../../../components/auth/RegistrationFieldsForm'; import type { FunnelStepProfileForm } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import useProfileForm from '../../../hooks/useProfileForm'; import { withIsActiveGuard } from '../shared/withActiveGuard'; -import { sanitizeMessage } from '../shared'; +import { + funnelStepRail, + FunnelStepCtaWrapper, + sanitizeMessage, +} from '../shared'; + +const PROFILE_FORM_ID = 'funnel-profile-form'; function InnerFunnelProfileForm({ parameters: { headline, extraFields }, @@ -48,24 +52,39 @@ function InnerFunnelProfileForm({ }; return ( -
-
+ +
{headline && ( - )}
-
+ ); } diff --git a/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.tsx b/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.tsx index 0ace2d88f5c..5f07fe068b7 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.tsx @@ -1,26 +1,43 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import type { FunnelStepReadingReminder } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import { ReadingReminder } from '../../../components/onboarding'; +import { useReadingReminder } from '../../../components/onboarding/useReadingReminder'; import { withIsActiveGuard } from '../shared/withActiveGuard'; import { useViewSize, ViewSize } from '../../../hooks'; import { usePushNotificationContext } from '../../../contexts/PushNotificationContext'; import { withShouldSkipStepGuard } from '../shared/withShouldSkipStepGuard'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; function FunnelReadingReminderComponent({ parameters: { headline }, onTransition, }: FunnelStepReadingReminder): ReactElement | null { + const state = useReadingReminder({ + onClickNext: () => + onTransition({ type: FunnelStepTransitionType.Complete }), + }); + return ( -
- - onTransition({ type: FunnelStepTransitionType.Complete }) - } - /> -
+ +
+ +
+
); } diff --git a/packages/shared/src/features/onboarding/steps/FunnelUploadCv.tsx b/packages/shared/src/features/onboarding/steps/FunnelUploadCv.tsx index 8c903275b07..63135c58632 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelUploadCv.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelUploadCv.tsx @@ -1,9 +1,11 @@ import React, { useRef, useState, useEffect } from 'react'; +import classNames from 'classnames'; import type { ReactElement } from 'react'; import type { FunnelStepUploadCv } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { FunnelStepCtaWrapper } from '../shared'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; import { withIsActiveGuard } from '../shared/withActiveGuard'; import { withShouldSkipStepGuard } from '../shared/withShouldSkipStepGuard'; import { UploadCv } from '../components/UploadCv'; @@ -16,6 +18,8 @@ function FunnelUploadCvComponent({ onTransition, }: FunnelStepUploadCv): ReactElement | null { const { user } = useAuthContext(); + // Shared with the paid funnel, which keeps its original chrome. + const isOnboarding = useIsOnboardingFunnel(); const { onUpload, status, isSuccess } = useUploadCv({ shouldOpenModal: false, }); @@ -34,15 +38,41 @@ function FunnelUploadCvComponent({ return ( onTransition({ type: FunnelStepTransitionType.Skip }), + }, + })} onClick={handleComplete} - containerClassName="flex w-full flex-1 flex-col items-center justify-center overflow-hidden" + containerClassName={ + isOnboarding + ? 'flex w-full flex-1 flex-col items-center overflow-hidden' + : 'flex w-full flex-1 flex-col items-center justify-center overflow-hidden' + } > - onUpload(file)} - status={status} - /> +
+ onUpload(file)} + status={status} + /> +
); } diff --git a/packages/shared/src/features/onboarding/steps/FunnelVerifyEmail.tsx b/packages/shared/src/features/onboarding/steps/FunnelVerifyEmail.tsx new file mode 100644 index 00000000000..d41190fa7ef --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelVerifyEmail.tsx @@ -0,0 +1,118 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { FunnelStepTransitionType } from '../types/funnel'; +import type { FunnelStepVerifyEmail } from '../types/funnel'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; +import { withIsActiveGuard } from '../shared/withActiveGuard'; +import { + OnboardingHeadline, + OnboardingSubheadline, +} from '../../../components/onboarding/common'; +import { CodeField } from '../../../components/fields/CodeField'; +import { MailIcon } from '../../../components/icons'; +import { AlertDot, AlertColor } from '../../../components/AlertDot'; +import { IconSize } from '../../../components/Icon'; +import { ClickableText } from '../../../components/buttons/ClickableText'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import useTimer from '../../../hooks/useTimer'; + +const CODE_LENGTH = 6; +const RESEND_SECONDS = 60; +const noop = (): void => undefined; + +/** + * The funnel's own copy of the email-verification screen. The auth flow keeps + * its original (`EmailCodeVerification` inside `AuthOptionsInner`) — this one + * exists so the step reads like the rest of the onboarding: shared headline and + * subheadline, the 440px rail, and the docked glass CTA instead of a button + * sitting in the form. + */ +function FunnelVerifyEmailComponent({ + parameters: { + headline = 'Verify your email', + explainer = 'A verification code has been sent to:', + cta = 'Verify', + email = '', + }, + onTransition, +}: FunnelStepVerifyEmail): ReactElement | null { + const [code, setCode] = useState(''); + const { timer, setTimer, runTimer } = useTimer(noop, RESEND_SECONDS); + const resendTimer = timer ?? 0; + + const complete = () => + onTransition({ + type: FunnelStepTransitionType.Complete, + details: { code }, + }); + + return ( + +
+ {/* No tile, no border: just the envelope in the headline's own color + with the product's unread dot (AlertDot, cabbage) on its shoulder — + "you've got mail, go open it" said with two existing pieces. */} + + + + + {headline} + + {explainer} + {!!email && ( + <> +
+ {email} + + )} +
+ + Don't see it? Also check your spam or junk folder. + + +
+ + Didn't get a verification code? + + 0} + onClick={() => { + setTimer(RESEND_SECONDS); + runTimer(); + }} + > + {resendTimer === 0 ? 'Resend code' : `Resend code ${resendTimer}s`} + +
+
+
+ ); +} + +export const FunnelVerifyEmail = withIsActiveGuard(FunnelVerifyEmailComponent); diff --git a/packages/shared/src/features/onboarding/steps/index.ts b/packages/shared/src/features/onboarding/steps/index.ts index 5352bf46933..d927ddf0254 100644 --- a/packages/shared/src/features/onboarding/steps/index.ts +++ b/packages/shared/src/features/onboarding/steps/index.ts @@ -13,3 +13,4 @@ export { FunnelPlusCards } from './FunnelPlusCards'; export { FunnelOrganicCheckout } from './FunnelOrganicCheckout'; export { FunnelBrowserExtension } from './FunnelBrowserExtension'; export { FunnelUploadCv } from './FunnelUploadCv'; +export { FunnelVerifyEmail } from './FunnelVerifyEmail'; diff --git a/packages/shared/src/features/onboarding/types/funnel.ts b/packages/shared/src/features/onboarding/types/funnel.ts index 7a431ba9f6f..0b40f89d798 100644 --- a/packages/shared/src/features/onboarding/types/funnel.ts +++ b/packages/shared/src/features/onboarding/types/funnel.ts @@ -18,6 +18,9 @@ export enum FunnelStepType { Fact = 'fact', Quiz = 'quiz', Signup = 'registration', + // The funnel's own copy of the auth verification screen — see + // FunnelVerifyEmail. The auth flow keeps its original. + VerifyEmail = 'verifyEmail', Pricing = 'pricing', Checkout = 'checkout', PaymentSuccessful = 'paymentSuccessful', @@ -410,6 +413,16 @@ export interface FunnelStepUploadCv onTransition: FunnelStepTransitionCallback; } +export interface FunnelStepVerifyEmail + extends FunnelStepCommon<{ + headline?: string; + explainer?: string; + email?: string; + }> { + type: FunnelStepType.VerifyEmail; + onTransition: FunnelStepTransitionCallback<{ code: string }>; +} + export type FunnelStep = | FunnelStepLandingPage | FunnelStepFact @@ -431,7 +444,8 @@ export type FunnelStep = | FunnelStepHeroLanding | FunnelStepBrowserExtension | FunnelStepPlusCards - | FunnelStepUploadCv; + | FunnelStepUploadCv + | FunnelStepVerifyEmail; export type FunnelPosition = { chapter: number; @@ -474,6 +488,9 @@ export const stepsWithOnlySkipHeader: Array<(typeof stepsWithHeader)[number]> = export const stepsFullWidth: Array = [ FunnelStepType.OrganicSignup, FunnelStepType.HeroLanding, + // The onboarding steps size themselves from `funnelStepRail`; the stepper's + // own narrower column would clamp the rail below the width it caps at. + FunnelStepType.ProfileForm, FunnelStepType.EditTags, FunnelStepType.ContentTypes, FunnelStepType.PlusCards, diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 0d244dab67a..12d305d0e68 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -246,6 +246,17 @@ export const featureHijackingVariants = new Feature( HijackingVariant.Default, ); +export enum OnboardingChromeVariant { + /** Control: the brand gradient canvas, no progress dots. */ + Gradient = 'gradient', + /** Variant: the animated edge-aura frame plus dots under the CTA. */ + Aura = 'aura', +} +export const featureOnboardingChrome = new Feature( + 'onboarding_chrome', + OnboardingChromeVariant.Gradient, +); + export const featureLayoutV2 = new Feature('layout_v2', false); export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index d6f972b47a0..e07cb9e75ff 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -911,12 +911,6 @@ meter::-webkit-meter-bar { background: radial-gradient(94% 48.83% at 50% 0%, var(--theme-accent-cabbage-default) 0%, var(--theme-accent-onion-default) 75.12%, var(--theme-accent-onion-baseline) 100%); } - &-onboarding-tall { - background: - radial-gradient(120% 30% at 100% 0%, var(--theme-accent-cabbage-default) 0%, var(--theme-accent-cabbage-baseline) 100%), - radial-gradient(120% 30% at 0% 0%, var(--theme-accent-onion-default) 0%, var(--theme-accent-onion-baseline) 100%); - } - &-hourglass { background: radial-gradient(192.5% 100% at 50% 100%, var(--theme-accent-cabbage-default) 0%, var(--theme-accent-cabbage-baseline) 50%), radial-gradient(192.5% 100% at 50% 0%, var(--theme-accent-onion-default) 0%, var(--theme-accent-onion-baseline) 50%); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index db9a698343e..4c9b3a8fe51 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -17,7 +17,14 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve" + "jsx": "preserve", + "paths": { + // `moduleResolution: node` predates package `exports` maps, so TS cannot + // follow edge-aura's "./react" subpath on its own. Bundlers do read the + // map, so the import specifier stays `edge-aura/react` and only the + // type lookup is pointed at the file behind it. + "edge-aura/react": ["./node_modules/edge-aura/dist/react.d.ts"] + } }, "include": [ "next-env.d.ts", diff --git a/packages/storybook/stories/components/onboarding/SignupFunnelSteps.stories.tsx b/packages/storybook/stories/components/onboarding/SignupFunnelSteps.stories.tsx new file mode 100644 index 00000000000..4a3ba7434e6 --- /dev/null +++ b/packages/storybook/stories/components/onboarding/SignupFunnelSteps.stories.tsx @@ -0,0 +1,706 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import { FunnelProfileForm } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelProfileForm'; +import { FunnelEditTags } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelEditTags'; +import { FunnelContentTypes } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelContentTypes'; +import { FunnelReadingReminder } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelReadingReminder'; +import { FunnelInstallPwa } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelInstallPwa'; +import { FunnelBrowserExtension } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelBrowserExtension'; +import { FunnelUploadCv } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelUploadCv'; +import { FunnelPlusCards } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelPlusCards'; +import { FunnelVerifyEmail } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelVerifyEmail'; +import { exportLinkedIn } from '@dailydotdev/shared/src/lib/image'; +import { FunnelPaymentPricingContext } from '@dailydotdev/shared/src/contexts/payment/context'; +import { mockPricing } from './FunnelPricing.stories'; +import { FunnelStepType } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { OnboardingChromeVariant } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + FunnelStepShell, + fakeIOSUserAgent, + FEED_SETTINGS_HANDLERS, + MockPlusPaymentProvider, +} from './signupFunnel.mocks'; +import { + OnboardingHeadline, + OnboardingSubheadline, +} from '@dailydotdev/shared/src/components/onboarding/common'; + +/** + * The steps a user walks through after registering on `/onboarding`, mounted + * one per story so the CTA placement can be compared across them. The two + * overview stories put them side by side in equal-width frames — mobile at + * 390px, desktop at 1440px — and every step's CTA bar should hit the same left + * and right edge. + */ +/** + * Every step story takes the experiment arm as an arg, so the overview pages can + * render the same nine steps twice — once per arm — by appending it to the + * iframe URL. + */ +interface StepArgs { + chrome?: OnboardingChromeVariant; +} + +const chromeArgTypes: Meta['argTypes'] = { + chrome: { + control: { type: 'inline-radio' }, + options: Object.values(OnboardingChromeVariant), + }, +}; + +const meta: Meta = { + title: 'Components/Onboarding/Signup funnel steps', + argTypes: chromeArgTypes, + args: { chrome: OnboardingChromeVariant.Gradient }, + // No `themes` parameter on purpose: it fights the global decorator, and the + // funnel's own always-dark steps then invert into the wrong direction. Use + // the toolbar's theme switcher instead. + parameters: { + layout: 'fullscreen', + // The feed-settings mutations are optimistic: `onMutate` flips the card and + // `onError` rolls it straight back. Without a resolving endpoint every + // toggle would snap back a frame later and the steps could not be clicked + // through at all. These resolve the writes so selection sticks. + msw: { handlers: FEED_SETTINGS_HANDLERS }, + }, +}; + +export default meta; + +type Story = StoryObj; + +// The step components take their own slice of the FunnelStep union; the stories +// build plain objects and let each component read what it needs. +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- story fixtures +const baseStep: any = { + isActive: true, + transitions: [], + onTransition: fn(), + onRegisterStepToSkip: fn(), +}; + +export const VerifyEmail: Story = { + name: '2. Verify email', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'verify-email', + type: FunnelStepType.VerifyEmail, + parameters: { + headline: 'Verify your email', + explainer: 'A verification code has been sent to:', + email: 'tsmatliah+22@gmail.com', + cta: 'Verify', + }, + }; + + return ( + + + + ); + }, +}; + +export const AccountDetails: Story = { + name: '1. Account details', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'profile-form', + type: FunnelStepType.ProfileForm, + parameters: { headline: 'Tell us a bit about yourself' }, + }; + + return ( + + + + ); + }, +}; + +export const PickTags: Story = { + name: '3. Pick tags', + // Five tags are pre-selected, so raising the requirement above 5 shows the + // gated state where the CTA bar fades out until enough tags are picked. + argTypes: { + ...chromeArgTypes, + minimumRequirement: { control: { type: 'number', min: 1 } }, + }, + args: { minimumRequirement: 5 }, + render: ({ + chrome, + minimumRequirement, + }: StepArgs & { minimumRequirement?: number }) => { + const step = { + ...baseStep, + id: 'edit-tags', + type: FunnelStepType.EditTags, + parameters: { + headline: 'Pick tags that are relevant to you', + minimumRequirement, + }, + }; + + return ( + + + + ); + }, +}; + +export const ContentTypes: Story = { + name: '4. Content types', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'content-types', + type: FunnelStepType.ContentTypes, + parameters: { + headline: 'What kind of posts would you like to see on your feed?', + }, + }; + + return ( + + + + ); + }, +}; + +export const ReadingReminder: Story = { + name: '5. Reading reminder', + parameters: { + // The step renders on mobile only (`ViewSize.MobileXL` is true below 500px). + docs: { description: { story: 'Renders below 500px viewport width.' } }, + }, + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'reading-reminder', + type: FunnelStepType.ReadingReminder, + parameters: { headline: 'When do you need that reading nudge?' }, + }; + + return ( + + + + ); + }, +}; + +export const InstallPwa: Story = { + name: '6. Install PWA', + decorators: [ + (Story) => { + // iOS Safari only; faked so the step can be reviewed on desktop. + fakeIOSUserAgent(); + return ; + }, + ], + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'install-pwa', + type: FunnelStepType.InstallPwa, + parameters: { headline: 'Add daily.dev to Home Screen' }, + }; + + return ( + + + + ); + }, +}; + +export const BrowserExtension: Story = { + name: '9. Browser extension', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'browser-extension', + type: FunnelStepType.BrowserExtension, + parameters: { + headline: 'Transform every new tab into a learning powerhouse', + explainer: + 'Unlock the power of every new tab with daily.dev extension. Personalized feed, developer communities, AI search and more!', + cta: 'Add to {browser}', + skip: 'Dare to skip? You might miss out.', + showReviews: false, + }, + }; + + return ( + + + + ); + }, +}; + +export const UploadCv: Story = { + name: '7. Upload CV', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'upload-cv', + type: FunnelStepType.UploadCv, + parameters: { + headline: 'Your next job should apply to you', + description: + 'Upload your CV so we quietly match you with roles you might actually want. Nothing is shared without your ok.', + dragDropDescription: 'Drag & Drop your CV or', + ctaDesktop: 'Browse files', + ctaMobile: 'Upload CV', + linkedin: { + cta: 'Go to your LinkedIn profile', + image: exportLinkedIn, + headline: 'Export from LinkedIn', + explainer: "Here's how to get your CV from LinkedIn:", + steps: [ + 'Go to your LinkedIn profile', + 'Click "Resources" \u2192 "Save to PDF"', + 'Download the file and upload it here', + ], + }, + }, + }; + + return ( + + + + ); + }, +}; + +export const PlusCards: Story = { + name: '8. Plus', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'plus-cards', + type: FunnelStepType.PlusCards, + parameters: { + headline: 'Fast-track your growth', + explainer: + "Work smarter, learn faster, and stay ahead with AI tools, custom feeds, and pro features. Because copy-pasting code isn't a long-term strategy.", + }, + }; + + return ( + + + + + + + + ); + }, +}; + +// The rail caps at 440px of content inside a 32rem (512px) box, so that is +// where every CTA should start and end. Below 512px the cap does nothing and +// only the rail's px-6 is left. +const railInset = (frameWidth: number) => + Math.max(24, (frameWidth - 512) / 2 + 24); + +const useThemeClass = (): 'dark' | 'light' => { + const [theme, setTheme] = React.useState<'dark' | 'light'>('light'); + + React.useEffect(() => { + const read = () => + setTheme( + document.documentElement.classList.contains('dark') ? 'dark' : 'light', + ); + read(); + const observer = new MutationObserver(read); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + }, []); + + return theme; +}; + +// The order a user actually meets them. +const OVERVIEW_STEPS = [ + { id: 'account-details', label: '1. Account details' }, + { id: 'verify-email', label: '2. Verify email' }, + { id: 'pick-tags', label: '3. Pick tags' }, + { id: 'content-types', label: '4. Content types' }, + // Both of these render on mobile only in the real funnel, so their desktop + // frames are legitimately empty. + { id: 'reading-reminder', label: '5. Reading reminder', isMobileOnly: true }, + { id: 'install-pwa', label: '6. Install PWA', isMobileOnly: true }, + { id: 'upload-cv', label: '7. Upload CV' }, + { id: 'plus-cards', label: '8. Plus' }, + { id: 'browser-extension', label: '9. Browser extension' }, +]; + +interface StepFramesProps { + // Which arm of the onboarding-chrome experiment the frames render. + arm?: OnboardingChromeVariant; + width: number; + height: number; + // Desktop frames are rendered at their real width and scaled down, so six of + // them can still be compared side by side. + scale?: number; + showGuides?: boolean; + title: string; + description: string; +} + +const StepFrames = ({ + arm = OnboardingChromeVariant.Gradient, + width, + height, + scale = 1, + showGuides, + title, + description, +}: StepFramesProps): ReactElement => { + // The frames are separate documents, so the toolbar's theme has to be + // forwarded to them explicitly. The theme decorator sets the class after the + // story renders, hence the observer rather than a render-time read. + const theme = useThemeClass(); + const inset = railInset(width); + const [reloadKey, setReloadKey] = React.useState(0); + // Mount the frames one at a time. + // + // Nine iframes booting at once each pull the same module graph, and a cold + // Vite server answers that by re-running dep optimization — which invalidates + // the URLs the in-flight requests are already using. Every frame then dies + // with "Failed to fetch dynamically imported module". Reloading them together + // just repeats the stampede. Staggering means the first frame warms the + // optimizer and the rest are served from a settled graph. + const [mounted, setMounted] = React.useState(1); + React.useEffect(() => { + setMounted(1); + }, [reloadKey]); + React.useEffect(() => { + if (mounted >= OVERVIEW_STEPS.length) { + return undefined; + } + const timeout = setTimeout(() => setMounted((count) => count + 1), 450); + + return () => clearTimeout(timeout); + }, [mounted]); + // ViewSize.MobileXL — the cut-off the mobile-only steps guard on. + const isMobileFrame = width < 500; + + return ( +
+
+
+

{title}

+

{description}

+
+ +
+
+ {OVERVIEW_STEPS.map(({ id, label, isMobileOnly }, frameIndex) => ( +
+
+ {label} + {isMobileOnly && !isMobileFrame && ( + + {' '} + — mobile only, skipped here + + )} +
+
+
+ {frameIndex < mounted ? ( +