diff --git a/packages/shared/__tests__/helpers/qr.ts b/packages/shared/__tests__/helpers/qr.ts new file mode 100644 index 00000000000..757b6e15889 --- /dev/null +++ b/packages/shared/__tests__/helpers/qr.ts @@ -0,0 +1,96 @@ +import jsQR from 'jsqr'; + +// The repo ships two hand-committed QR matrices (the header get-app asset and +// the onboarding signup panel). Their destination URLs live in code, but the +// matrices are opaque path strings - if one moves without the other being +// regenerated, the only symptom is a scan landing on a stale destination. +// These helpers parse a committed path back into a module matrix and decode it +// the way a phone camera would (via jsQR), so a spec can assert the committed +// pixels still say what the code claims. Decoding rather than re-encoding +// keeps the assertion valid regardless of which generator or mask pattern +// produced the asset. + +type Matrix = boolean[][]; + +// Stroke-run format, as emitted by `npx qrcode -t svg`: rows drawn as +// 1px-tall horizontal strokes on half-pixel centres, e.g. +// `M4 4.5h7m2 0h2` = at row 4 draw 7 modules from col 4, skip 2, draw 2. +// `quietZone` is the generator's fixed 4-module margin baked into the coords. +export const parseStrokePath = (d: string, quietZone = 4): Matrix => { + const cells: Array<[number, number]> = []; + const rowRuns = d.split('M').filter(Boolean); + + rowRuns.forEach((run) => { + const [, xStart, yCentre] = run.match(/^([\d.]+) ([\d.]+)/) ?? []; + const row = Math.floor(Number(yCentre)) - quietZone; + let col = Number(xStart) - quietZone; + + Array.from(run.matchAll(/([hm])(-?[\d.]+)/g)).forEach(([, op, a]) => { + const n = Number(a); + if (op === 'h') { + for (let i = 0; i < n; i += 1) { + cells.push([row, col + i]); + } + } + col += n; + }); + }); + + const size = Math.max(...cells.map(([row]) => row)) + 1; + const matrix: Matrix = Array.from({ length: size }, () => + Array.from({ length: size }, () => false), + ); + cells.forEach(([row, col]) => { + matrix[row][col] = true; + }); + + return matrix; +}; + +// Rect format, as used by the hand-flattened onboarding matrix: +// `M0 0h7v1h-7z` = a 7-module run at row 0, col 0. Coordinates are already +// module-space (the quiet zone lives in the viewBox origin instead). +export const parseRectPath = (d: string): Matrix => { + const rects = Array.from(d.matchAll(/M(\d+) (\d+)h(\d+)v1h-\3z/g)); + const size = Math.max(...rects.map(([, , y]) => Number(y))) + 1; + const matrix: Matrix = Array.from({ length: size }, () => + Array.from({ length: size }, () => false), + ); + + rects.forEach(([, x, y, w]) => { + for (let i = 0; i < Number(w); i += 1) { + matrix[Number(y)][Number(x) + i] = true; + } + }); + + return matrix; +}; + +// Rasterises the matrix into greyscale RGBA (scaled up, with a real quiet +// zone) and runs the same decoder a scanner pipeline would. +export const decodeMatrix = (matrix: Matrix): string | null => { + const scale = 4; + const quiet = 4 * scale; + const px = matrix.length * scale + quiet * 2; + const rgba = new Uint8ClampedArray(px * px * 4).fill(255); + + matrix.forEach((rowCells, row) => { + rowCells.forEach((dark, col) => { + if (!dark) { + return; + } + for (let dy = 0; dy < scale; dy += 1) { + for (let dx = 0; dx < scale; dx += 1) { + const x = quiet + col * scale + dx; + const y = quiet + row * scale + dy; + const at = (y * px + x) * 4; + rgba[at] = 0; + rgba[at + 1] = 0; + rgba[at + 2] = 0; + } + } + }); + }); + + return jsQR(rgba, px, px)?.data ?? null; +}; diff --git a/packages/shared/package.json b/packages/shared/package.json index 0972a29239c..7ed91eb415d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -80,6 +80,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-junit": "^12.3.0", + "jsqr": "^1.4.0", "next": "16.2.6", "nock": "^13.3.1", "postcss": "^8.5.13", diff --git a/packages/shared/src/components/layout/HeaderButtons.tsx b/packages/shared/src/components/layout/HeaderButtons.tsx index e3109e2802d..78251d27702 100644 --- a/packages/shared/src/components/layout/HeaderButtons.tsx +++ b/packages/shared/src/components/layout/HeaderButtons.tsx @@ -52,7 +52,6 @@ export function HeaderButtons({ {additionalButtons} - diff --git a/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx index 8fc63f8bd01..1c80be88a92 100644 --- a/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx +++ b/packages/shared/src/features/getApp/components/GetAppButton.spec.tsx @@ -4,25 +4,24 @@ 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 { isIOSNative } from '../../../lib/func'; import { appStoreUrl, playStoreUrl } from '../../../lib/constants'; -jest.mock('../../../hooks/useConditionalFeature', () => ({ - useConditionalFeature: jest.fn(), +jest.mock('../../../lib/func', () => ({ + ...jest.requireActual('../../../lib/func'), + isIOSNative: jest.fn(), })); -jest.mock('../../../hooks', () => ({ - ...jest.requireActual('../../../hooks'), - useViewSize: jest.fn(), -})); - -const mockUseConditionalFeature = useConditionalFeature as jest.Mock; -const mockUseViewSize = useViewSize as jest.Mock; +const mockIsIOSNative = isIOSNative as jest.Mock; +// TestBootProvider defaults to a logged-in session; this surface is for +// anonymous visitors, so the tests start logged out and opt in explicitly. const renderComponent = (props = {}, auth = {}) => render( - + , ); @@ -31,12 +30,11 @@ const triggerName = /get the daily\.dev mobile app/i; beforeEach(() => { jest.clearAllMocks(); - mockUseViewSize.mockReturnValue(true); - mockUseConditionalFeature.mockReturnValue({ value: true, isLoading: false }); + mockIsIOSNative.mockReturnValue(false); }); describe('GetAppButton', () => { - it('should render the trigger when the feature is enabled on desktop', () => { + it('should render the trigger for anonymous desktop visitors', () => { renderComponent(); expect( @@ -44,45 +42,44 @@ describe('GetAppButton', () => { ).toBeInTheDocument(); }); - it('should render nothing when the feature is off', () => { - mockUseConditionalFeature.mockReturnValue({ - value: false, - isLoading: false, - }); - renderComponent(); + it('should render nothing for logged-in users', () => { + renderComponent({}, { isLoggedIn: true }); expect( screen.queryByRole('button', { name: triggerName }), ).not.toBeInTheDocument(); }); - it('should render nothing below laptop, where the app is already at hand', () => { - mockUseViewSize.mockReturnValue(false); + // The desktop-only half of the gate is CSS, not JS, so the SSR HTML already + // contains the pill and hydration doesn't reflow Log in / Sign up. jsdom + // applies no stylesheets, so the assertable contract is the classes. + it('should gate the desktop breakpoint in CSS to avoid a hydration reflow', () => { renderComponent(); - expect( - screen.queryByRole('button', { name: triggerName }), - ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: triggerName })).toHaveClass( + 'hidden', + 'laptop:flex', + ); }); - // 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 }); + // The wrappers render this same webapp shell and a tablet/desktop-mode + // viewport can satisfy the laptop breakpoint from inside the app, so both + // need a JS veto the CSS gate can't provide. iOS is detected through its + // WebKit runtime bridge. + it('should render nothing inside the iOS app even at laptop width', () => { + mockIsIOSNative.mockReturnValue(true); + renderComponent(); expect( screen.queryByRole('button', { name: triggerName }), ).not.toBeInTheDocument(); }); - it('should not evaluate the flag when the caller already resolved it', () => { - renderComponent({ isFeatureEnabled: false }); + // Android has no isIOSNative()-style runtime bridge and is flagged through + // boot data instead. + it('should render nothing inside the Android app even at laptop width', () => { + renderComponent({}, { isAndroidApp: true }); - expect(mockUseConditionalFeature).toHaveBeenCalledWith( - expect.objectContaining({ shouldEvaluate: false }), - ); expect( screen.queryByRole('button', { name: triggerName }), ).not.toBeInTheDocument(); diff --git a/packages/shared/src/features/getApp/components/GetAppButton.tsx b/packages/shared/src/features/getApp/components/GetAppButton.tsx index a36ad04a845..249840ec5f6 100644 --- a/packages/shared/src/features/getApp/components/GetAppButton.tsx +++ b/packages/shared/src/features/getApp/components/GetAppButton.tsx @@ -13,10 +13,6 @@ 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'; @@ -52,50 +48,33 @@ const stores: AppStore[] = [ }, ]; -// 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. + // The logged-out header has room for the full label next to Log in / + // Sign up; icon-only (label in a tooltip) exists as the compact alternative. 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 { isLoggedIn, isAndroidApp } = useAuthContext(); + // This entry point is for *anonymous desktop* visitors only - logged-in + // users made a product call to keep their action rail clean, so the gate + // lives here rather than trusting every call site. The desktop half is + // gated in CSS (`hidden laptop:flex` on the trigger, matching LoginButton + // and ProfileButton in the same row) so the server HTML already contains + // the pill and hydration doesn't reflow Log in / Sign up on first paint. + // The native wrappers still need JS vetoes on top: they render this same + // webapp shell, a tablet/desktop-mode viewport can satisfy the laptop + // breakpoint from inside the app, and nobody should be told to go get an + // app they're already holding. iOS exposes a WebKit bridge at runtime + // (isIOSNative); Android has no such bridge and is flagged through boot + // data instead. + const shouldRender = !isLoggedIn && !isIOSNative() && !isAndroidApp; const onOpenChange = useCallback( (open: boolean) => { @@ -105,13 +84,12 @@ export function GetAppButton({ return; } - setHasSeen(true); logEvent({ event_name: LogEvent.Click, target_type: TargetType.GetAppButton, }); }, - [logEvent, setHasSeen], + [logEvent], ); const onStoreClick = useCallback( @@ -125,45 +103,31 @@ export function GetAppButton({ [logEvent], ); - if (!shouldRender || !isFeatureEnabled) { + if (!shouldRender) { 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 && ( - - )} - + + + + + { + const svg = readFileSync(join(__dirname, 'getAppQr.svg'), 'utf8'); + const d = svg.match(/ { + expect(d).toBeTruthy(); + + expect(decodeMatrix(parseStrokePath(d))).toBe(EXPECTED_URL); + }); + + it('should keep the module count error correction H produces', () => { + expect(parseStrokePath(d).length).toBe(EXPECTED_SIZE); + }); + + it('should declare a viewBox matching the matrix plus the quiet zone', () => { + const viewBox = svg.match(/viewBox="0 0 (\d+) (\d+)"/); + const size = parseStrokePath(d).length; + + expect(Number(viewBox?.[1])).toBe(size + 8); + expect(Number(viewBox?.[2])).toBe(size + 8); + }); +}); diff --git a/packages/shared/src/features/getApp/icons/getAppQr.svg b/packages/shared/src/features/getApp/icons/getAppQr.svg index cc13cf6389d..3572dd8c781 100644 --- a/packages/shared/src/features/getApp/icons/getAppQr.svg +++ b/packages/shared/src/features/getApp/icons/getAppQr.svg @@ -1,9 +1,23 @@ - - + and keep ONLY the stroke path plus this viewBox. The white background rect + the generator adds must be dropped: the SVGR configs rewrite #ffffff to + currentcolor, which would turn it black; the parent plate supplies the + white. Note: double hyphens are illegal inside XML comments, so no long + CLI flags in this note. --> + diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx index 111b4392d81..5499bd450b4 100644 --- a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx +++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx @@ -1,6 +1,15 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { APP_URL, LandingAppInstall, VISIBLE_LABEL } from './LandingAppInstall'; +import { + APP_URL, + LandingAppInstall, + QR_PATH, + VISIBLE_LABEL, +} from './LandingAppInstall'; +import { + decodeMatrix, + parseRectPath, +} from '../../../../../__tests__/helpers/qr'; describe('LandingAppInstall', () => { it('exposes the app-store destination as a link, not only as a QR code', () => { @@ -32,4 +41,16 @@ describe('LandingAppInstall', () => { screen.getByRole('link', { name: /iOS or Android/i }), ).toBeInTheDocument(); }); + // The matrix is a hand-flattened constant while the destination lives in + // APP_URL; nothing else notices if one moves without the other. Decoding the + // committed modules the way a scanner would makes CI catch that drift. + it('keeps the QR matrix in sync with APP_URL at error correction H', () => { + const matrix = parseRectPath(QR_PATH); + + expect(decodeMatrix(matrix)).toBe(APP_URL); + // Decoding alone would still pass at a weaker level, and the logo badge + // overlaps modules that only H's recovery budget can spare. 29 modules is + // version 3 at H for this URL; a weaker level shrinks the matrix. + expect(matrix.length).toBe(29); + }); }); diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx index d2c48ba5f23..4a549e331ba 100644 --- a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx +++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx @@ -34,7 +34,9 @@ const QR_MODULES = 29; // inside the same box. Level H's 30% recovery covers the shortfall. const QR_QUIET_ZONE = 2; const QR_SIZE = QR_MODULES + QR_QUIET_ZONE * 2; -const QR_PATH = +// Exported so the spec can parse it back into a module matrix and assert it +// still encodes APP_URL (see __tests__/helpers/qr.ts). +export const QR_PATH = 'M0 0h7v1h-7zM9 0h1v1h-1zM13 0h1v1h-1zM16 0h1v1h-1zM18 0h1v1h-1zM22 0h7v1h-7zM0 1h1v1h-1zM6 1h1v1h-1zM12 1h2v1h-2zM15 1h1v1h-1zM17 1h2v1h-2zM20 1h1v1h-1zM22 1h1v1h-1zM28 1h1v1h-1zM0 2h1v1h-1zM2 2h3v1h-3zM6 2h1v1h-1zM9 2h2v1h-2zM13 2h1v1h-1zM16 2h1v1h-1zM19 2h2v1h-2zM22 2h1v1h-1zM24 2h3v1h-3zM28 2h1v1h-1zM0 3h1v1h-1zM2 3h3v1h-3zM6 3h1v1h-1zM12 3h3v1h-3zM17 3h1v1h-1zM22 3h1v1h-1zM24 3h3v1h-3zM28 3h1v1h-1zM0 4h1v1h-1zM2 4h3v1h-3zM6 4h1v1h-1zM8 4h1v1h-1zM10 4h1v1h-1zM12 4h1v1h-1zM14 4h2v1h-2zM17 4h1v1h-1zM19 4h1v1h-1zM22 4h1v1h-1zM24 4h3v1h-3zM28 4h1v1h-1zM0 5h1v1h-1zM6 5h1v1h-1zM10 5h2v1h-2zM13 5h1v1h-1zM15 5h1v1h-1zM19 5h1v1h-1zM22 5h1v1h-1zM28 5h1v1h-1zM0 6h7v1h-7zM8 6h1v1h-1zM10 6h1v1h-1zM12 6h1v1h-1zM14 6h1v1h-1zM16 6h1v1h-1zM18 6h1v1h-1zM20 6h1v1h-1zM22 6h7v1h-7zM8 7h2v1h-2zM11 7h1v1h-1zM13 7h1v1h-1zM15 7h1v1h-1zM17 7h1v1h-1zM19 7h1v1h-1zM2 8h2v1h-2zM6 8h3v1h-3zM20 8h3v1h-3zM24 8h1v1h-1zM0 9h2v1h-2zM7 9h5v1h-5zM17 9h4v1h-4zM22 9h5v1h-5zM28 9h1v1h-1zM1 10h4v1h-4zM6 10h1v1h-1zM13 10h1v1h-1zM15 10h4v1h-4zM22 10h1v1h-1zM26 10h2v1h-2zM0 11h3v1h-3zM9 11h1v1h-1zM11 11h2v1h-2zM15 11h6v1h-6zM23 11h1v1h-1zM28 11h1v1h-1zM0 12h1v1h-1zM3 12h2v1h-2zM6 12h1v1h-1zM9 12h3v1h-3zM13 12h2v1h-2zM16 12h2v1h-2zM21 12h1v1h-1zM23 12h1v1h-1zM25 12h4v1h-4zM0 13h2v1h-2zM5 13h1v1h-1zM8 13h7v1h-7zM17 13h4v1h-4zM22 13h2v1h-2zM25 13h2v1h-2zM28 13h1v1h-1zM1 14h2v1h-2zM4 14h1v1h-1zM6 14h4v1h-4zM11 14h1v1h-1zM13 14h2v1h-2zM16 14h1v1h-1zM18 14h2v1h-2zM21 14h5v1h-5zM27 14h2v1h-2zM1 15h1v1h-1zM4 15h2v1h-2zM7 15h1v1h-1zM10 15h6v1h-6zM19 15h1v1h-1zM21 15h2v1h-2zM24 15h2v1h-2zM0 16h2v1h-2zM4 16h1v1h-1zM6 16h6v1h-6zM13 16h1v1h-1zM16 16h2v1h-2zM20 16h1v1h-1zM23 16h3v1h-3zM28 16h1v1h-1zM1 17h1v1h-1zM3 17h2v1h-2zM9 17h4v1h-4zM16 17h1v1h-1zM18 17h1v1h-1zM20 17h1v1h-1zM23 17h1v1h-1zM25 17h2v1h-2zM0 18h1v1h-1zM2 18h6v1h-6zM10 18h1v1h-1zM12 18h2v1h-2zM15 18h1v1h-1zM18 18h1v1h-1zM20 18h1v1h-1zM22 18h1v1h-1zM24 18h2v1h-2zM4 19h2v1h-2zM7 19h2v1h-2zM15 19h2v1h-2zM19 19h1v1h-1zM21 19h2v1h-2zM26 19h3v1h-3zM1 20h3v1h-3zM5 20h2v1h-2zM13 20h1v1h-1zM17 20h1v1h-1zM19 20h10v1h-10zM8 21h1v1h-1zM10 21h1v1h-1zM15 21h1v1h-1zM17 21h4v1h-4zM24 21h2v1h-2zM27 21h2v1h-2zM0 22h7v1h-7zM8 22h1v1h-1zM10 22h3v1h-3zM16 22h2v1h-2zM19 22h2v1h-2zM22 22h1v1h-1zM24 22h1v1h-1zM26 22h2v1h-2zM0 23h1v1h-1zM6 23h1v1h-1zM9 23h1v1h-1zM11 23h1v1h-1zM15 23h4v1h-4zM20 23h1v1h-1zM24 23h1v1h-1zM27 23h2v1h-2zM0 24h1v1h-1zM2 24h3v1h-3zM6 24h1v1h-1zM9 24h5v1h-5zM17 24h2v1h-2zM20 24h8v1h-8zM0 25h1v1h-1zM2 25h3v1h-3zM6 25h1v1h-1zM8 25h2v1h-2zM11 25h2v1h-2zM16 25h4v1h-4zM24 25h2v1h-2zM27 25h1v1h-1zM0 26h1v1h-1zM2 26h3v1h-3zM6 26h1v1h-1zM8 26h1v1h-1zM10 26h2v1h-2zM13 26h1v1h-1zM20 26h1v1h-1zM23 26h1v1h-1zM25 26h2v1h-2zM28 26h1v1h-1zM0 27h1v1h-1zM6 27h1v1h-1zM10 27h3v1h-3zM14 27h2v1h-2zM17 27h1v1h-1zM20 27h1v1h-1zM22 27h1v1h-1zM24 27h2v1h-2zM27 27h1v1h-1zM0 28h7v1h-7zM9 28h1v1h-1zM12 28h2v1h-2zM16 28h2v1h-2zM19 28h5v1h-5zM27 28h1v1h-1z'; export const LandingAppInstall = ({ diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 8973137660b..0d244dab67a 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -323,9 +323,3 @@ 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/storybook/stories/components/GetAppButton.stories.tsx b/packages/storybook/stories/components/GetAppButton.stories.tsx index 54c6fdc46af..b05674af04d 100644 --- a/packages/storybook/stories/components/GetAppButton.stories.tsx +++ b/packages/storybook/stories/components/GetAppButton.stories.tsx @@ -9,7 +9,6 @@ import { Button, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; -import { BellIcon } from '@dailydotdev/shared/src/components/icons'; const queryClient = new QueryClient(); @@ -22,11 +21,14 @@ const meta: Meta = { 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. */} + {/* The render gate reads isLoggedIn and isAndroidApp from AuthContext + (the Android wrapper has no runtime bridge), so the story must + provide the anonymous context the button ships to. */}
@@ -35,8 +37,6 @@ const meta: Meta = { ), ], - // Storybook has no GrowthBook instance, so the flag is forced on here. - args: { isFeatureEnabled: true }, }; export default meta; @@ -55,21 +55,6 @@ const HeaderShell = ({
); -export const InHeaderLoggedIn: Story = { - render: (args) => ( - - -