Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/shared/src/components/icons/GooglePlay/filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions packages/shared/src/components/icons/GooglePlay/index.tsx
Original file line number Diff line number Diff line change
@@ -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 => (
<Icon {...props} IconPrimary={FilledIcon} IconSecondary={FilledIcon} />
);
6 changes: 6 additions & 0 deletions packages/shared/src/components/layout/HeaderButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +32,10 @@ export function HeaderButtons({
if (!isLoggedIn) {
return (
<Container>
{/* 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. */}
<GetAppButton showLabel />
<LoginButton
className={{
container: 'gap-4',
Expand All @@ -47,6 +52,7 @@ export function HeaderButtons({
<QuestHeaderButton />
<GivebackGiftEntry compact={!isLaptop} />
{additionalButtons}
<GetAppButton />
<NotificationsBell />
<ProfileButton className="hidden laptop:flex" />
</Container>
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/components/layout/MainLayoutHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ function MainLayoutHeader({
<div
className={classNames(
'left-0 top-0 z-header mx-2 items-center py-3 tablet:left-16 laptop:left-0',
// Every header child is flex-shrink:0 via the global reset, so a
// crowded action rail overflows the header instead of compressing
// it. The search is the one element that can afford to give up
// width, so it absorbs the squeeze at desktop widths. Laptop-scoped
// so the mobile search page keeps its original layout.
'laptop:min-w-0 laptop:flex-1',
isSearchPage
? 'relative right-0 tablet:!left-0 laptop:top-0'
: 'hidden laptop:flex',
Expand Down
11 changes: 7 additions & 4 deletions packages/shared/src/components/spotlight/SpotlightTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ export const SpotlightTrigger = ({
'hover:bg-surface-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-cabbage-default focus-visible:ring-offset-2',
// Same compact desktop width used by SearchPanelInput in production
// (26.25rem default, capped at 29.5rem on laptop and 35rem on
// laptopL). Without this the trigger stretches edge-to-edge,
// which read as a different field even though the styling matched.
'laptop:w-[26.25rem] laptop:max-w-[29.5rem] laptop:py-1 laptop:backdrop-blur-[3.75rem] laptopL:max-w-[35rem]',
// (26.25rem). Without this the trigger stretches edge-to-edge, which
// read as a different field even though the styling matched. It is a
// cap rather than a fixed width so the field can give up space when a
// crowded header action rail would otherwise overflow; the larger
// laptop/laptopL max-widths that used to sit here were dead, since the
// fixed width never reached them.
'laptop:max-w-[26.25rem] laptop:py-1 laptop:backdrop-blur-[3.75rem]',
className,
)}
>
Expand Down
114 changes: 114 additions & 0 deletions packages/shared/src/features/getApp/components/GetAppButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TestBootProvider client={new QueryClient()} auth={auth}>
<GetAppButton {...props} />
</TestBootProvider>,
);

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();
});
});
212 changes: 212 additions & 0 deletions packages/shared/src/features/getApp/components/GetAppButton.tsx
Original file line number Diff line number Diff line change
@@ -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<IconProps>;
// 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<boolean>(
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 (
<Popover open={isOpen} onOpenChange={onOpenChange}>
{/* 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. */}
<span className={classNames('relative inline-flex', className)}>
<Tooltip
content="Get the mobile app"
side="bottom"
visible={!showLabel}
>
<PopoverTrigger asChild>
<Button
variant={ButtonVariant.Float}
size={showLabel ? ButtonSize.Medium : undefined}
className={classNames('justify-center', !showLabel && 'w-10')}
icon={<PhoneIcon secondary={isOpen} />}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-label="Get the daily.dev mobile app"
>
{showLabel ? 'Get the app' : null}
</Button>
</PopoverTrigger>
</Tooltip>
{showDot && (
<span
aria-hidden
className="pointer-events-none absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-background-default bg-accent-cabbage-default"
/>
)}
</span>
<PopoverContent
align="end"
sideOffset={8}
className="z-popup w-96 rounded-16 border border-border-subtlest-tertiary bg-background-popover p-4"
>
<div className="flex gap-4">
{/* Level H is a denser matrix than the plain code was, so it gets a
bigger plate to keep the modules comfortably scannable. */}
<GetAppQrCode className="size-32 shrink-0" />
{/* 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. */}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
<p className="font-bold text-text-primary typo-callout">
daily.dev on your phone
</p>
<p className="text-text-tertiary typo-footnote">
Scan the code with your camera. Your feed, bookmarks and streak
come with you.
</p>
</div>
</div>
<div className="mt-4 flex gap-2 border-t border-border-subtlest-tertiary pt-4">
{stores.map((store) => (
<Button
key={store.id}
tag="a"
href={store.href}
target="_blank"
rel="noopener"
variant={ButtonVariant.Primary}
size={ButtonSize.Medium}
className="flex-1"
icon={<store.Icon size={store.iconSize} />}
onClick={() => onStoreClick(store)}
>
{store.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
);
}

export default GetAppButton;
Loading
Loading