diff --git a/packages/shared/src/components/icons/GooglePlay/filled.svg b/packages/shared/src/components/icons/GooglePlay/filled.svg new file mode 100644 index 00000000000..8ebe3d60100 --- /dev/null +++ b/packages/shared/src/components/icons/GooglePlay/filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/shared/src/components/icons/GooglePlay/index.tsx b/packages/shared/src/components/icons/GooglePlay/index.tsx new file mode 100644 index 00000000000..00fa91cbbc6 --- /dev/null +++ b/packages/shared/src/components/icons/GooglePlay/index.tsx @@ -0,0 +1,9 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import FilledIcon from './filled.svg'; + +export const GooglePlayIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/layout/HeaderButtons.tsx b/packages/shared/src/components/layout/HeaderButtons.tsx index f1939108c8a..e3109e2802d 100644 --- a/packages/shared/src/components/layout/HeaderButtons.tsx +++ b/packages/shared/src/components/layout/HeaderButtons.tsx @@ -10,6 +10,7 @@ import { useViewSize, ViewSize } from '../../hooks'; import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; import { QuestHeaderButton } from '../header/QuestHeaderButton'; import { GivebackGiftEntry } from '../../features/giveback/components/GivebackGiftEntry'; +import { GetAppButton } from '../../features/getApp/components/GetAppButton'; interface HeaderButtonsProps { additionalButtons?: ReactNode; @@ -31,6 +32,10 @@ export function HeaderButtons({ if (!isLoggedIn) { return ( + {/* Sits ahead of the Log in / Sign up pair. It keeps its label here + because there is room, but it stays Float (not Primary) so it never + competes with Sign up, which is the CTA that matters logged out. */} + {additionalButtons} + diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx index 0d814c6c7c3..0cf03a33e34 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -72,6 +72,12 @@ function MainLayoutHeader({
diff --git a/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx new file mode 100644 index 00000000000..8fc63f8bd01 --- /dev/null +++ b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { GetAppButton } from './GetAppButton'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { useViewSize } from '../../../hooks'; +import { appStoreUrl, playStoreUrl } from '../../../lib/constants'; + +jest.mock('../../../hooks/useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useViewSize: jest.fn(), +})); + +const mockUseConditionalFeature = useConditionalFeature as jest.Mock; +const mockUseViewSize = useViewSize as jest.Mock; + +const renderComponent = (props = {}, auth = {}) => + render( + + + , + ); + +const triggerName = /get the daily\.dev mobile app/i; + +beforeEach(() => { + jest.clearAllMocks(); + mockUseViewSize.mockReturnValue(true); + mockUseConditionalFeature.mockReturnValue({ value: true, isLoading: false }); +}); + +describe('GetAppButton', () => { + it('should render the trigger when the feature is enabled on desktop', () => { + renderComponent(); + + expect( + screen.getByRole('button', { name: triggerName }), + ).toBeInTheDocument(); + }); + + it('should render nothing when the feature is off', () => { + mockUseConditionalFeature.mockReturnValue({ + value: false, + isLoading: false, + }); + renderComponent(); + + expect( + screen.queryByRole('button', { name: triggerName }), + ).not.toBeInTheDocument(); + }); + + it('should render nothing below laptop, where the app is already at hand', () => { + mockUseViewSize.mockReturnValue(false); + renderComponent(); + + expect( + screen.queryByRole('button', { name: triggerName }), + ).not.toBeInTheDocument(); + }); + + // The Android wrapper has no isIOSNative()-style runtime bridge and is + // flagged through boot data instead. A tablet/desktop-mode viewport can + // satisfy the laptop breakpoint from inside the app, so the boot signal must + // veto the viewport gate. + it('should render nothing inside the Android app even at laptop width', () => { + renderComponent({}, { isAndroidApp: true }); + + expect( + screen.queryByRole('button', { name: triggerName }), + ).not.toBeInTheDocument(); + }); + + it('should not evaluate the flag when the caller already resolved it', () => { + renderComponent({ isFeatureEnabled: false }); + + expect(mockUseConditionalFeature).toHaveBeenCalledWith( + expect.objectContaining({ shouldEvaluate: false }), + ); + expect( + screen.queryByRole('button', { name: triggerName }), + ).not.toBeInTheDocument(); + }); + + it('should show the QR code and both store links once opened', async () => { + renderComponent(); + + await userEvent.click(screen.getByRole('button', { name: triggerName })); + + expect( + await screen.findByRole('img', { name: /qr code/i }), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'App Store' })).toHaveAttribute( + 'href', + appStoreUrl, + ); + expect(screen.getByRole('link', { name: 'Google Play' })).toHaveAttribute( + 'href', + playStoreUrl, + ); + }); + + it('should render the label instead of the tooltip when asked', () => { + renderComponent({ showLabel: true }); + + expect(screen.getByText('Get the app')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/getApp/components/GetAppButton.tsx b/packages/shared/src/features/getApp/components/GetAppButton.tsx new file mode 100644 index 00000000000..a36ad04a845 --- /dev/null +++ b/packages/shared/src/features/getApp/components/GetAppButton.tsx @@ -0,0 +1,212 @@ +import type { ComponentType, ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import { Popover, PopoverTrigger } from '@radix-ui/react-popover'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { PopoverContent } from '../../../components/popover/Popover'; +import { Tooltip } from '../../../components/tooltip/Tooltip'; +import { AppleIcon, PhoneIcon } from '../../../components/icons'; +import { GooglePlayIcon } from '../../../components/icons/GooglePlay'; +import type { IconProps } from '../../../components/Icon'; +import { IconSize } from '../../../components/Icon'; +import { useViewSize, ViewSize } from '../../../hooks'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import usePersistentContext from '../../../hooks/usePersistentContext'; +import { featureHeaderGetApp } from '../../../lib/featureManagement'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { isIOSNative } from '../../../lib/func'; +import { appStoreUrl, playStoreUrl } from '../../../lib/constants'; +import { GetAppQrCode } from './GetAppQrCode'; + +interface AppStore { + id: string; + label: string; + href: string; + Icon: ComponentType; + // Optically balanced, not equal: the Apple glyph sits inside its viewBox at + // roughly 67% width, while the Play mark runs nearly edge to edge. Rendering + // both at one size makes Apple look shrunken, so Apple gets the larger box. + iconSize: IconSize; +} + +const stores: AppStore[] = [ + { + id: 'ios', + label: 'App Store', + href: appStoreUrl, + Icon: AppleIcon, + iconSize: IconSize.XSmall, + }, + { + id: 'android', + label: 'Google Play', + href: playStoreUrl, + Icon: GooglePlayIcon, + iconSize: IconSize.Size16, + }, +]; + +// One-time attention dot. The button is a permanent affordance, not a campaign, +// so it nudges once and then goes quiet - a banner that keeps reappearing is +// what we're deliberately not building here. +const GET_APP_SEEN_KEY = 'getAppHeaderSeen'; + +export interface GetAppButtonProps { + // The logged-out header has room for the full label; the logged-in action + // rail (opportunity, quests, giveback, bell, avatar) does not, so it gets the + // icon-only trigger with the label in a tooltip. + showLabel?: boolean; + className?: string; + // Escape hatch for surfaces that already resolved `featureHeaderGetApp` (and + // for Storybook, which has no GrowthBook instance). Left undefined, the + // button evaluates the flag itself. + isFeatureEnabled?: boolean; +} + +export function GetAppButton({ + showLabel = false, + className, + isFeatureEnabled: isFeatureEnabledProp, +}: GetAppButtonProps): ReactElement | null { + const [isOpen, setIsOpen] = useState(false); + const { logEvent } = useLogContext(); + const { isAndroidApp } = useAuthContext(); + const isLaptop = useViewSize(ViewSize.Laptop); + // The point of this entry point is telling *desktop* visitors that a mobile + // app exists. Below laptop we're either on mobile web or inside the native + // wrapper - which renders this same webapp shell - and neither should be + // told to go get an app they're already holding. The wrappers need their own + // signals on top of the viewport gate because a tablet/desktop-mode viewport + // can satisfy the laptop breakpoint from inside the app: iOS exposes a + // WebKit bridge at runtime (isIOSNative), Android has no such bridge and is + // flagged through boot data instead. + const shouldRender = isLaptop && !isIOSNative() && !isAndroidApp; + const { value: isFlagEnabled } = useConditionalFeature({ + feature: featureHeaderGetApp, + shouldEvaluate: shouldRender && isFeatureEnabledProp === undefined, + }); + const isFeatureEnabled = isFeatureEnabledProp ?? isFlagEnabled; + const [hasSeen, setHasSeen, isSeenFetched] = usePersistentContext( + GET_APP_SEEN_KEY, + false, + ); + + const onOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); + + if (!open) { + return; + } + + setHasSeen(true); + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.GetAppButton, + }); + }, + [logEvent, setHasSeen], + ); + + const onStoreClick = useCallback( + (store: AppStore) => { + logEvent({ + event_name: LogEvent.DownloadApp, + target_type: TargetType.GetAppButton, + target_id: store.id, + }); + }, + [logEvent], + ); + + if (!shouldRender || !isFeatureEnabled) { + return null; + } + + const showDot = !showLabel && isSeenFetched && !hasSeen; + + return ( + + {/* The dot is a sibling of the Button, not a child: Button derives its + icon-only geometry from `!children`, so nesting the dot inside would + switch it to the labelled layout (`pl-2 pr-4 gap-1`) and push the + glyph 4px off centre. */} + + + + + + + {showDot && ( + + )} + + +
+ {/* Level H is a denser matrix than the plain code was, so it gets a + bigger plate to keep the modules comfortably scannable. */} + + {/* The global reset pins `flex-shrink: 0`, so the copy needs both + `flex-1` and `min-w-0` or it blows past the panel instead of + wrapping. */} +
+

+ daily.dev on your phone +

+

+ Scan the code with your camera. Your feed, bookmarks and streak + come with you. +

+
+
+
+ {stores.map((store) => ( + + ))} +
+
+
+ ); +} + +export default GetAppButton; diff --git a/packages/shared/src/features/getApp/components/GetAppQrCode.tsx b/packages/shared/src/features/getApp/components/GetAppQrCode.tsx new file mode 100644 index 00000000000..a0a01e8049f --- /dev/null +++ b/packages/shared/src/features/getApp/components/GetAppQrCode.tsx @@ -0,0 +1,53 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { DailyIcon } from '../../../components/icons'; +// A desktop visitor can't install a phone app from the machine they're on, so +// the header entry point hands them a scannable code instead of a dead-end +// store link. The asset is a pre-rendered SVG (see the regeneration note +// inside it) rather than a runtime QR library: the target URL never changes, +// so one static image beats shipping a generator in the header bundle. It +// lives under an `icons/` directory because the extension's rspack config +// only runs SVGR on paths matching `icons/`. +// +// Error correction is level H (recovers ~30% of the matrix) specifically so +// the centre can be knocked out for the logo badge below. Dropping back to +// level M would make the branded version unscannable. +import QrSvg from '../icons/getAppQr.svg'; + +interface GetAppQrCodeProps { + className?: string; +} + +export function GetAppQrCode({ className }: GetAppQrCodeProps): ReactElement { + return ( + // Scanners want dark modules on a light plate, so this stays white in both + // themes instead of inheriting the popover surface. +
+ + {/* Knockout badge for the brand mark. At a quarter of the code's width it + obscures ~6% of the modules, well inside what level H recovers. The + white ring stops the dark badge from merging into whichever modules + happen to sit against it. */} + + {/* DailyIcon draws in `currentColor`, so the badge's `text-white` makes + the mark white in both themes. The svg/LogoIcon variant hardcodes + `--theme-text-primary` and would flip with the theme instead. The + important modifiers override the Icon wrapper's fixed size so the + mark scales with the badge. */} + + +
+ ); +} + +export default GetAppQrCode; diff --git a/packages/shared/src/features/getApp/icons/getAppQr.svg b/packages/shared/src/features/getApp/icons/getAppQr.svg new file mode 100644 index 00000000000..cc13cf6389d --- /dev/null +++ b/packages/shared/src/features/getApp/icons/getAppQr.svg @@ -0,0 +1,9 @@ + + + + diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 0d244dab67a..8973137660b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -323,3 +323,9 @@ export const featurePostSignupActivation = new Feature( 'post_signup_activation', false, ); + +// Header entry point promoting the daily.dev mobile app to desktop webapp and +// extension visitors: a phone button in the header that opens a QR-code +// popover. Control hides it entirely. Keep the default `false` - GrowthBook +// ramps it. +export const featureHeaderGetApp = new Feature('header_get_app', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 6ead0526c33..6018410cc72 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -540,6 +540,7 @@ export enum TargetType { OnboardingChecklist = 'onboarding checklist', LoginButton = 'login button', SignupButton = 'signup button', + GetAppButton = 'get app button', SquadJoinButton = 'squad join button', SearchRecommendation = 'search rec', SearchHistory = 'search history', diff --git a/packages/storybook/stories/components/GetAppButton.stories.tsx b/packages/storybook/stories/components/GetAppButton.stories.tsx new file mode 100644 index 00000000000..54c6fdc46af --- /dev/null +++ b/packages/storybook/stories/components/GetAppButton.stories.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { GetAppButton } from '@dailydotdev/shared/src/features/getApp/components/GetAppButton'; +import { GetAppQrCode } from '@dailydotdev/shared/src/features/getApp/components/GetAppQrCode'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { BellIcon } from '@dailydotdev/shared/src/components/icons'; + +const queryClient = new QueryClient(); + +const meta: Meta = { + title: 'Components/GetAppButton', + component: GetAppButton, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + {/* The render gate reads isAndroidApp from AuthContext (the Android + wrapper has no runtime bridge), so the story must provide the + context the real app always has. */} + +
+ +
+
+
+ ), + ], + // Storybook has no GrowthBook instance, so the flag is forced on here. + args: { isFeatureEnabled: true }, +}; + +export default meta; +type Story = StoryObj; + +// Mirrors the real header shell so the trigger can be judged in context rather +// than floating on an empty canvas. +const HeaderShell = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+ daily.dev +
{children}
+
+); + +export const InHeaderLoggedIn: Story = { + render: (args) => ( + + + + + +); + +export const InHeaderLoggedOut: Story = { + args: { showLabel: true }, + render: (args) => ( + + + + + ), +}; + +// Same slot, icon-only. Worth comparing: with Log in AND Sign up already in the +// row, a third labelled button competes with Sign up, which is the CTA that +// actually matters to a logged-out visitor. +export const InHeaderLoggedOutCompact: Story = { + args: { showLabel: false }, + render: (args) => ( + + + + + ), +}; + +// The panel is the part worth reviewing closely; this pins it open next to the +// bare QR so contrast can be checked against both themes. +export const Panel: Story = { + render: (args) => ( +
+

+ Click the phone button to open the panel. +

+ + + + +
+ ), +};