diff --git a/.gitignore b/.gitignore index 8174566fc2f..24022923990 100644 --- a/.gitignore +++ b/.gitignore @@ -53,8 +53,9 @@ figma-images/ *.tsbuildinfo node-compile-cache/ -# Plan files -plans/*.md +# Plan files — the whole subtree, not just the top level: internal strategy +# docs must never reach this public repository at any nesting depth. +plans/ .cursor/plans/ # Claude Code runtime state (hooks/skills/settings remain tracked) diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 741005873cb..00000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,44 +0,0 @@ -# List the ports you want to expose and what to do when they are served. See https://www.gitpod.io/docs/config-ports/ -ports: - - port: 5002 - onOpen: open-browser - - port: 4000 - onOpen: ignore - - port: 5000 - onOpen: ignore - - port: 6379 - onOpen: ignore - - port: 5432 - onOpen: ignore - -# List the start up tasks. You can start them in parallel in multiple terminals. See https://www.gitpod.io/docs/config-start-tasks/ -tasks: - - name: docker-compose - init: docker-compose pull - command: docker-compose up - - name: seed - before: | - sudo apt-get update - sudo apt-get install -y netcat - init: | - sleep 60 - ./wait-for.sh localhost:5000 - command: | - docker exec apps-daily-api-1 node ./node_modules/typeorm/cli.js migration:run -d src/data-source.js - docker exec apps-daily-api-1 node bin/import.js - - name: webapp - env: - NEXT_PUBLIC_API_URL: http://localhost:5000 - NEXT_PUBLIC_SUBS_URL: ws://localhost:5000/graphql - NEXT_PUBLIC_DOMAIN: localhost - NEXT_PUBLIC_WEBAPP_URL: / - before: | - nvm install - nvm use - init: | - corepack enable - corepack prepare pnpm@10.33.4 --activate - pnpm install - command: | - cd packages/webapp - npm run dev:notls diff --git a/README.md b/README.md index 0b42d17e05f..e53250007b5 100644 --- a/README.md +++ b/README.md @@ -62,17 +62,6 @@ contains a collection of smaller projects or libraries that are used across the The web app project. This is a Next.js project and has more pages than the extension, such as a registration page, post page, profile page, etc. For more information [click here](https://github.com/dailydotdev/apps/tree/master/packages/webapp). -## Local Environment - -To spin up a local environment, you will need Docker. Do the steps below and you should be able to start trying to center a div: - -- Fork this repo -- Pull it locally -- Run `docker compose up` -- Once done, seed your local data by running `docker compose exec daily-api node ./bin/import` -- Then lastly, run npm run dev:oss -- The app should run at `http://localhost:5002/` - ## Want to Help? So you want to contribute to daily.dev app suite and make an impact, we are glad to hear it. :heart_eyes: diff --git a/packages/extension/package.json b/packages/extension/package.json index 061e943a2f7..7660140ff3d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "extension", - "version": "3.46.5", + "version": "3.46.7", "scripts": { "dev": "cross-env NODE_ENV=development cross-env TARGET_BROWSER=chrome rspack build -c rspack.config.js --watch", "build": "cross-env NODE_ENV=production cross-env TARGET_BROWSER=chrome rspack build -c rspack.config.js", diff --git a/packages/extension/src/newtab/ExtensionTopBanners.tsx b/packages/extension/src/newtab/ExtensionTopBanners.tsx index 6d67ff6dbfd..d2bb34abd9d 100644 --- a/packages/extension/src/newtab/ExtensionTopBanners.tsx +++ b/packages/extension/src/newtab/ExtensionTopBanners.tsx @@ -5,6 +5,8 @@ import { TopHero } from '@dailydotdev/shared/src/components/marketing/banners/He import { useReadingReminderHero } from '@dailydotdev/shared/src/hooks/notifications/useReadingReminderHero'; import { fileValidation, + uploadCvOpportunitySuccessContent, + uploadCvProfileSuccessContent, useUploadCv, } from '@dailydotdev/shared/src/features/profile/hooks/useUploadCv'; import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; @@ -23,6 +25,7 @@ import { cloudinaryShortcutsIconsReddit, uploadCvBgMobile, } from '@dailydotdev/shared/src/lib/image'; +import { useJobsFeature } from '@dailydotdev/shared/src/hooks/useJobsFeature'; // Bare-illustration frame matched across the three top cards so they // line up vertically. Slightly wider than tall to give the CV cluster @@ -93,14 +96,20 @@ type UseShortcutsOnboardingResult = { const useShortcutsOnboarding = (): UseShortcutsOnboardingResult => { const hubEnabled = useIsShortcutsHubEnabled(); - const { showTopSites, toggleShowTopSites } = useSettingsContext(); + const { showTopSites, toggleShowTopSites, loadedSettings } = + useSettingsContext(); const { openModal } = useLazyModal(); - const { completeAction, checkHasCompleted } = useActions(); - const { shortcutLinks } = useShortcutLinks(); + const { completeAction, checkHasCompleted, isActionsFetched } = useActions(); + const { shortcutLinks, hasCheckedPermission } = useShortcutLinks(); const hasShortcuts = (shortcutLinks?.length ?? 0) > 0; const hasClosedBanner = checkHasCompleted(ActionType.ClosedShortcutsBanner); - const shouldShow = !hasShortcuts && !hasClosedBanner; + const shouldShow = + isActionsFetched && + loadedSettings && + !!hasCheckedPermission && + !hasShortcuts && + !hasClosedBanner; const completeFirstSession = () => { if (!checkHasCompleted(ActionType.FirstShortcutsSession)) { @@ -130,7 +139,12 @@ export const ExtensionTopBanners = (): ReactElement | null => { // new tabs, which is where the extension lives). const reminder = useReadingReminderHero({ requireMobile: false }); const { isLoggedIn, isAuthReady } = useAuthContext(); - const { onUpload, shouldShow: shouldShowCv } = useUploadCv(); + const { isJobsEnabled } = useJobsFeature(); + const { onUpload, shouldShow: shouldShowCv } = useUploadCv({ + modalContent: isJobsEnabled + ? uploadCvOpportunitySuccessContent + : uploadCvProfileSuccessContent, + }); const { completeAction } = useActions(); const fileInputRef = useRef(null); const shortcuts = useShortcutsOnboarding(); @@ -164,7 +178,11 @@ export const ExtensionTopBanners = (): ReactElement | null => { cards.push( } onCtaClick={() => fileInputRef.current?.click()} diff --git a/packages/extension/src/newtab/MainFeedPage.tsx b/packages/extension/src/newtab/MainFeedPage.tsx index 8afd086b0d2..295f556a1d8 100644 --- a/packages/extension/src/newtab/MainFeedPage.tsx +++ b/packages/extension/src/newtab/MainFeedPage.tsx @@ -17,8 +17,6 @@ import { SearchProviderEnum } from '@dailydotdev/shared/src/graphql/search'; import { LogEvent } from '@dailydotdev/shared/src/lib/log'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import { useFeedLayout } from '@dailydotdev/shared/src/hooks'; -import { useDailyPage } from '@dailydotdev/shared/src/hooks/feed/useDailyPage'; -import { DailyHome } from '@dailydotdev/shared/src/features/daily/DailyHome'; import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import { useShortcutLinks } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutLinks'; import { useDndContext } from '@dailydotdev/shared/src/contexts/DndContext'; @@ -84,9 +82,6 @@ const MainFeedPageInner = ({ useCompanionSettings(); const { isActive: isDndActive, showDnd, setShowDnd } = useDndContext(); const { isCustomDefaultFeed } = useCustomDefaultFeed(); - const { isDailyAsDefault, setShowDaily } = useDailyPage(); - const showDailyHome = - feedName === 'default' && !isSearchOn && isDailyAsDefault; useLayoutEffect(() => { if (!initialPage || !shouldInitializeCurrentPage) { @@ -180,49 +175,40 @@ const MainFeedPageInner = ({ } > - {showDailyHome ? ( - { - setShowDaily(false); - window.scrollTo({ top: 0 }); - }} - /> - ) : ( - { - logEvent({ - event_name: LogEvent.SubmitSearch, - extra: JSON.stringify({ - query, - provider: SearchProviderEnum.Posts, - ...extraFlags, - }), - }); - - setSearchQuery(query); - }} - onFocus={() => { - logEvent({ event_name: LogEvent.FocusSearch }); - }} - /> - } - shortcuts={ - isV2 - ? undefined - : shortcuts ?? ( - - ) - } - /> - )} + { + logEvent({ + event_name: LogEvent.SubmitSearch, + extra: JSON.stringify({ + query, + provider: SearchProviderEnum.Posts, + ...extraFlags, + }), + }); + + setSearchQuery(query); + }} + onFocus={() => { + logEvent({ event_name: LogEvent.FocusSearch }); + }} + /> + } + shortcuts={ + isV2 + ? undefined + : shortcuts ?? ( + + ) + } + /> setShowDnd(false)} /> diff --git a/packages/shared/__tests__/fixture/post.ts b/packages/shared/__tests__/fixture/post.ts index 4dcbdbdcefa..1326842f198 100644 --- a/packages/shared/__tests__/fixture/post.ts +++ b/packages/shared/__tests__/fixture/post.ts @@ -25,6 +25,46 @@ const post: Post = { type: PostType.Article, }; +export const postWithCommunitySentiment: Post = { + ...post, + communitySentiment: { + breakdown: { positive: 62, mixed: 24, critical: 14 }, + tldr: 'Most agree it is worth reading.', + postCount: 2, + sources: ['Hacker News', 'Lobsters'], + pros: ['Clear explanation'], + cons: ['Skips some trade-offs'], + bySource: [ + { + source: 'Hacker News', + lean: 'positive', + note: 'Mostly supportive', + url: 'https://news.ycombinator.com/item?id=1', + }, + ], + hottestDebate: 'Whether the advice applies broadly.', + openQuestions: ['How does it behave at scale?'], + highlights: [ + { + quote: 'This helped clarify the trade-off.', + author: 'someone', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=2', + metrics: { points: 214, replies: 88 }, + }, + ], + discussions: [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 214, + commentsCount: 88, + }, + ], + updatedAt: '2026-07-18T00:00:00.000Z', + }, +}; + export const sharePost: Post = { id: '5nLQHVNHi', title: 'Good read about react-query', diff --git a/packages/shared/__tests__/helpers/media.ts b/packages/shared/__tests__/helpers/media.ts new file mode 100644 index 00000000000..5fb5b8a2e15 --- /dev/null +++ b/packages/shared/__tests__/helpers/media.ts @@ -0,0 +1,28 @@ +// The global stub in `setup.ts` never matches and carries only the legacy +// `addListener`, which libraries calling `addEventListener` blow up on. +export const mockMatchMedia = ( + matches: (query: string) => boolean = () => false, +): void => { + (global.matchMedia as jest.Mock).mockImplementation((query: string) => ({ + media: query, + matches: matches(query), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + onchange: null, + })); +}; + +export const laptopQuery = '(min-width: 1020px)'; +export const noHoverQuery = '(hover: none)'; + +// A 1020px window matches every width breakpoint at or below laptop, so +// answering only the laptop query reports a desktop that is not a tablet. +export const mockDesktop = (): void => + mockMatchMedia((query) => { + const [, min] = /min-width:\s*(\d+)px/.exec(query) ?? []; + + return !!min && Number(min) <= 1020; + }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 9085d7fff94..e60ee3a478c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -126,6 +126,7 @@ "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", "@zumer/snapdom": "^2.23.1", + "border-beam": "1.3.0", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", "edge-aura": "0.6.0", diff --git a/packages/shared/src/components/Custom404.spec.tsx b/packages/shared/src/components/Custom404.spec.tsx new file mode 100644 index 00000000000..eece4a247d8 --- /dev/null +++ b/packages/shared/src/components/Custom404.spec.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import Custom404 from './Custom404'; +import { webappUrl } from '../lib/constants'; + +const renderComponent = (showRecoveryLinks = false) => + render(); + +describe('Custom404', () => { + it('should render the not-found container', () => { + renderComponent(); + + expect(screen.getByTestId('notFound')).toBeInTheDocument(); + }); + + it('should keep a primary route home', () => { + renderComponent(); + + expect(screen.getByRole('link', { name: 'Go home' })).toHaveAttribute( + 'href', + '/', + ); + }); + + it('should not render the recovery nav by default', () => { + renderComponent(); + + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + }); + + it.each([ + ['Explore', 'posts'], + ['Tags', 'tags'], + ['Sources', 'sources'], + ['Squads', 'squads/discover'], + ])('should offer %s as a recovery link', (label, path) => { + renderComponent(true); + + expect(screen.getByRole('link', { name: label })).toHaveAttribute( + 'href', + `${webappUrl}${path}`, + ); + }); + + it('should group the recovery links in a labelled nav', () => { + renderComponent(true); + + const nav = screen.getByRole('navigation', { name: 'Other places to go' }); + + expect(within(nav).getAllByRole('link')).toHaveLength(4); + }); + + it('should build every recovery href from the per-build webapp prefix', () => { + renderComponent(true); + + const nav = screen.getByRole('navigation', { name: 'Other places to go' }); + + within(nav) + .getAllByRole('link') + .forEach((link) => { + const href = link.getAttribute('href') ?? ''; + + expect(href.startsWith(webappUrl)).toBe(true); + expect(href).not.toContain('//squads'); + }); + }); +}); diff --git a/packages/shared/src/components/Custom404.tsx b/packages/shared/src/components/Custom404.tsx index f2ff4a9dd53..4d90b0d4843 100644 --- a/packages/shared/src/components/Custom404.tsx +++ b/packages/shared/src/components/Custom404.tsx @@ -5,12 +5,36 @@ import { PageContainer } from './utilities'; import { Button, ButtonVariant } from './buttons/Button'; import { cloudinaryCharm404 } from '../lib/image'; import { Image } from './image/Image'; +import { squadCategoriesPaths, webappUrl } from '../lib/constants'; interface Custom404Props { children?: ReactNode; + /** + * Opt in to the secondary recovery nav. Off by default: this component + * also renders inside the post modal, the agent side pane and the + * extension new tab, where a full-site nav is the wrong furniture and + * navigating away is not what the surface wants. + */ + showRecoveryLinks?: boolean; } -export default function Custom404({ children }: Custom404Props): ReactElement { +// Absolute, because this component reaches the browser extension through +// BasePostContent, where a root-relative href resolves against +// chrome-extension:/// and dies. +const recoveryLinks = [ + { label: 'Explore', href: `${webappUrl}posts` }, + { label: 'Tags', href: `${webappUrl}tags` }, + { label: 'Sources', href: `${webappUrl}sources` }, + { + label: 'Squads', + href: `${webappUrl}${squadCategoriesPaths.discover.substring(1)}`, + }, +]; + +export default function Custom404({ + children, + showRecoveryLinks = false, +}: Custom404Props): ReactElement { return ( + + {showRecoveryLinks && ( + + )} ); diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 1157bc011db..515c80bc929 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -71,6 +71,7 @@ import { SourceType } from '../graphql/sources'; import { removeQueryParam } from '../lib/links'; import { SharedFeedPage } from './utilities'; import type { AllFeedPages } from '../lib/query'; +import { OtherFeedPage } from '../lib/query'; import { UserVoteEntity } from '../hooks'; import * as hooks from '../hooks/useViewSize'; import { ActionType } from '../graphql/actions'; @@ -1890,6 +1891,7 @@ interface HighlightLayoutRenderParams { disableAds?: boolean; user?: LoggedUser; isHorizontal?: boolean; + feedName?: AllFeedPages; } const renderWithHighlightLayout = ({ @@ -1906,6 +1908,7 @@ const renderWithHighlightLayout = ({ disableAds, user = defaultUser, isHorizontal, + feedName = SharedFeedPage.MyFeed, }: HighlightLayoutRenderParams): RenderResult => { variables = { ...defaultVariables, first: pageSize, columns: numCards }; mockGraphQL(createFeedMock(buildFeedPage(posts))); @@ -2004,7 +2007,7 @@ const renderWithHighlightLayout = ({ { expect(order[1]).toBe('postItem'); }); - it('places a marketing CTA at index 0 when asFirstCard is set', async () => { + it('renders a marketing CTA above the feed grid without displacing ads', async () => { const marketingCtaTitle = 'Marketing CTA title'; const marketingCta: MarketingCta = { campaignId: 'cta-test', @@ -2244,7 +2247,6 @@ describe('Feed ad cadence with highlight cards', () => { title: marketingCtaTitle, ctaText: 'Click me', ctaUrl: 'https://daily.dev/cta', - asFirstCard: true, }, }; jest.mocked(useBoot).mockReturnValue({ @@ -2270,11 +2272,6 @@ describe('Feed ad cadence with highlight cards', () => { buildPost('p8'), ]; - // adStart=2, adRepeat=4 → 3 slots at vcs 2, 6, 10. CTA pushed first - // shifts vcs by 1. Slot 0 (vcs=2) is skipped by asFirstCard; slots - // 1 and 2 (vcs=6, 10) fire. CTA itself has no postItem/adItem testid, - // so it's filtered out of the helper output — ads land at testid'd - // indices 5 and 9. renderWithHighlightLayout({ posts, highlightEnabled: false, @@ -2290,10 +2287,164 @@ describe('Feed ad cadence with highlight cards', () => { const adIndices = order .map((t, i) => (t === 'adItem' ? i : -1)) .filter((i) => i >= 0); - expect(adIndices).toEqual([5, 9]); + expect(adIndices).toEqual([2, 6]); expect(order.filter((t) => t === 'postItem').length).toBe(posts.length); }); + it('does not render marketing CTA on ads-disabled feeds', async () => { + const marketingCtaTitle = 'Should not appear'; + const marketingCta: MarketingCta = { + campaignId: 'cta-off', + variant: MarketingCtaVariant.Card, + createdAt: new Date(), + flags: { + title: marketingCtaTitle, + ctaText: 'Click me', + ctaUrl: 'https://daily.dev/cta', + }, + }; + jest.mocked(useBoot).mockReturnValue({ + addSquad: jest.fn(), + deleteSquad: jest.fn(), + updateSquad: jest.fn(), + getMarketingCta: jest.fn((variant) => + variant === MarketingCtaVariant.Card ? marketingCta : null, + ), + clearMarketingCta: jest.fn(), + getPlusEntryData: jest.fn().mockReturnValue(null), + }); + + const posts = [buildPost('p0'), buildPost('p1'), buildPost('p2')]; + + renderWithHighlightLayout({ + posts, + highlightEnabled: false, + disableAds: true, + }); + + await waitFor(() => { + expect(screen.queryAllByTestId('postItem').length).toBe(posts.length); + }); + expect(screen.queryByText(marketingCtaTitle)).not.toBeInTheDocument(); + }); + + it('holds the marketing CTA on squad feeds until enough posts load', async () => { + const marketingCtaTitle = 'Squad CTA'; + const marketingCta: MarketingCta = { + campaignId: 'cta-squad', + variant: MarketingCtaVariant.Card, + createdAt: new Date(), + flags: { + title: marketingCtaTitle, + ctaText: 'Click me', + ctaUrl: 'https://daily.dev/cta', + }, + }; + jest.mocked(useBoot).mockReturnValue({ + addSquad: jest.fn(), + deleteSquad: jest.fn(), + updateSquad: jest.fn(), + getMarketingCta: jest.fn((variant) => + variant === MarketingCtaVariant.Card ? marketingCta : null, + ), + clearMarketingCta: jest.fn(), + getPlusEntryData: jest.fn().mockReturnValue(null), + }); + + renderWithHighlightLayout({ + posts: [buildPost('p0')], + highlightEnabled: false, + feedName: OtherFeedPage.Squad, + }); + + await waitFor(() => { + expect(screen.queryAllByTestId('postItem').length).toBe(1); + }); + expect(screen.queryByText(marketingCtaTitle)).not.toBeInTheDocument(); + }); + + it('renders marketing CTA over acquisition form when both eligible', async () => { + jest.mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/', + query: { ua: 'true' }, + } as unknown as NextRouter), + ); + const marketingCtaTitle = 'Priority CTA'; + const marketingCta: MarketingCta = { + campaignId: 'cta-priority', + variant: MarketingCtaVariant.Card, + createdAt: new Date(), + flags: { + title: marketingCtaTitle, + ctaText: 'Click me', + ctaUrl: 'https://daily.dev/cta', + }, + }; + jest.mocked(useBoot).mockReturnValue({ + addSquad: jest.fn(), + deleteSquad: jest.fn(), + updateSquad: jest.fn(), + getMarketingCta: jest.fn((variant) => + variant === MarketingCtaVariant.Card ? marketingCta : null, + ), + clearMarketingCta: jest.fn(), + getPlusEntryData: jest.fn().mockReturnValue(null), + }); + + renderWithHighlightLayout({ + posts: [buildPost('p0'), buildPost('p1'), buildPost('p2')], + highlightEnabled: false, + }); + + expect( + await screen.findByText(marketingCtaTitle, undefined, { timeout: 5000 }), + ).toBeInTheDocument(); + expect( + screen.queryByText(/How did you hear about us/i), + ).not.toBeInTheDocument(); + }); + + it('shows the marketing CTA on squad feeds once the post threshold is exceeded', async () => { + const marketingCtaTitle = 'Squad CTA'; + const marketingCta: MarketingCta = { + campaignId: 'cta-squad-ok', + variant: MarketingCtaVariant.Card, + createdAt: new Date(), + flags: { + title: marketingCtaTitle, + ctaText: 'Click me', + ctaUrl: 'https://daily.dev/cta', + }, + }; + jest.mocked(useBoot).mockReturnValue({ + addSquad: jest.fn(), + deleteSquad: jest.fn(), + updateSquad: jest.fn(), + getMarketingCta: jest.fn((variant) => + variant === MarketingCtaVariant.Card ? marketingCta : null, + ), + clearMarketingCta: jest.fn(), + getPlusEntryData: jest.fn().mockReturnValue(null), + }); + + renderWithHighlightLayout({ + posts: [ + buildPost('p0'), + buildPost('p1'), + buildPost('p2'), + buildPost('p3'), + ], + highlightEnabled: false, + feedName: OtherFeedPage.Squad, + }); + + expect( + await screen.findByText(marketingCtaTitle, undefined, { timeout: 5000 }), + ).toBeInTheDocument(); + }); + it('renders highlight cards for Plus users without rendering ads', async () => { const posts = [ buildPost('p0'), diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index d979828dedc..8266aa2bd52 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -45,6 +45,14 @@ import type { AllFeedPages } from '../lib/query'; import { OtherFeedPage, RequestKey } from '../lib/query'; import { MarketingCtaVariant } from './marketing/cta/common'; +import { MarketingCtaCard } from './marketing/cta'; +import { MarketingCtaList } from './marketing/cta/MarketingCtaList'; +import { MarketingCtaBriefing } from './marketing/cta/MarketingCtaBriefing'; +import { MarketingCtaYearInReview } from './marketing/cta/MarketingCtaYearInReview'; +import { MarketingCtaVideo } from './marketing/cta/MarketingCtaVideo'; +import { AcquisitionFormGrid } from './cards/AcquisitionForm/AcquisitionFormGrid'; +import { AcquisitionFormList } from './cards/AcquisitionForm/AcquisitionFormList'; +import PlusGrid from './cards/plus/PlusGrid'; import { isNullOrUndefined } from '../lib/func'; import { useSearchResultsLayout } from '../hooks/search/useSearchResultsLayout'; import { SearchResultsLayout } from './search/SearchResults/SearchResultsLayout'; @@ -274,7 +282,53 @@ export default function Feed({ featureFeedAdTemplate.defaultValue?.default ?? { adStart: 1 }; const { isV2 } = useLayoutVariant(); - const showFirstSlotCard = showProfileCompletionCard || showBriefCard; + + const getFirstSlotCard = (): ReactElement | null => { + const canShowGrowthCta = + !disableAds && + !isHorizontal && + feedQueryKey?.[0] !== RequestKey.FeedPreview; + const canShowNonPlusCta = canShowGrowthCta && !user?.isPlus; + + if (canShowNonPlusCta && plusEntryFeed) { + return ; + } + if (canShowGrowthCta && showMarketingCta && marketingCta) { + if (marketingCta.variant === MarketingCtaVariant.BriefCard) { + return ; + } + if (marketingCta.variant === MarketingCtaVariant.YearInReview) { + return ; + } + if (marketingCta.variant === MarketingCtaVariant.Video) { + return ; + } + const Component = shouldUseListFeedLayout + ? MarketingCtaList + : MarketingCtaCard; + return ; + } + if (canShowNonPlusCta && showAcquisitionForm) { + const Component = shouldUseListFeedLayout + ? AcquisitionFormList + : AcquisitionFormGrid; + return ; + } + if (showProfileCompletionCard) { + return ; + } + if (showBriefCard) { + return ( + + ); + } + return null; + }; + + const eligibleFirstSlotCard = getFirstSlotCard(); const { items, placements: itemPlacements, @@ -287,6 +341,7 @@ export default function Feed({ isFetching, isInitialLoading, isError, + hasFirstSlotCard, error: feedError, } = useFeed( feedQueryKey, @@ -305,7 +360,7 @@ export default function Feed({ options, isBriefBannerEligible: !user?.isPlus && isMyFeed, engagementStripEligible: !isHorizontal && isEngagementAdFeed(feedName), - firstSlotOffset: Number(showFirstSlotCard), + firstSlotOffset: Number(eligibleFirstSlotCard !== null), disableTopHero: isV2, isHorizontal, excludePinnedPosts, @@ -313,9 +368,6 @@ export default function Feed({ disableAds, staticAd, adPostLength: isSquadFeed ? 2 : undefined, - showAcquisitionForm, - ...(showMarketingCta && { marketingCta }), - ...(plusEntryFeed && { plusEntry: plusEntryFeed }), feedName, }, }, @@ -474,13 +526,14 @@ export default function Feed({ const feedContextValue = useMemo(() => { return { queryKey: feedQueryKey, + feedName, items, logOpts, allowPin, origin, onRemovePost, }; - }, [feedQueryKey, items, logOpts, allowPin, origin, onRemovePost]); + }, [feedQueryKey, feedName, items, logOpts, allowPin, origin, onRemovePost]); const { ranking } = (variables as RankVariables) || {}; @@ -691,7 +744,7 @@ export default function Feed({ actionButtons, isHorizontal, feedContainerRef, - showBriefCard, + hasFirstSlotCard, disableListFrame, }; @@ -702,21 +755,7 @@ export default function Feed({ <>{emptyScreen} ) : ( <> - {showProfileCompletionCard && ( - - )} - {showBriefCard && !showProfileCompletionCard && ( - - )} + {hasFirstSlotCard && eligibleFirstSlotCard} {items.map((item, index) => { const placement = itemPlacements[index]; const { colSpan } = placement; diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index c4f111925bf..94629930ecc 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -14,15 +14,11 @@ import { LogEvent, Origin, TargetType } from '../lib/log'; import type { UseVotePost } from '../hooks'; import { useFeedLayout } from '../hooks'; import { CollectionList } from './cards/collection/CollectionList'; -import { MarketingCtaCard } from './marketing/cta'; -import { MarketingCtaList } from './marketing/cta/MarketingCtaList'; import { FeedItemType } from './cards/common/common'; import { AdGrid } from './cards/ad/AdGrid'; import { AdList } from './cards/ad/AdList'; import { SignalAdList } from './cards/ad/SignalAdList'; import type { AdCardProps } from './cards/ad/common/common'; -import { AcquisitionFormGrid } from './cards/AcquisitionForm/AcquisitionFormGrid'; -import { AcquisitionFormList } from './cards/AcquisitionForm/AcquisitionFormList'; import { FreeformGrid } from './cards/Freeform/FreeformGrid'; import { FreeformList } from './cards/Freeform/FreeformList'; import type { PostClick } from '../lib/click'; @@ -35,7 +31,6 @@ import { ShareList } from './cards/share/ShareList'; import { CollectionGrid } from './cards/collection'; import type { UseBookmarkPost } from '../hooks/useBookmarkPost'; import { AdActions } from '../lib/ads'; -import PlusGrid from './cards/plus/PlusGrid'; import { useFeedCardContext } from '../features/posts/FeedCardContext'; import { AdPixel } from './cards/ad/common/AdPixel'; import { AdMeasurement } from './cards/ad/common/AdMeasurement'; @@ -54,10 +49,6 @@ import { } from '../lib/engagementAds'; import { useEngagementAdsContext } from '../contexts/EngagementAdsContext'; import { useLogContext } from '../contexts/LogContext'; -import { MarketingCtaVariant } from './marketing/cta/common'; -import { MarketingCtaBriefing } from './marketing/cta/MarketingCtaBriefing'; -import { MarketingCtaYearInReview } from './marketing/cta/MarketingCtaYearInReview'; -import { MarketingCtaVideo } from './marketing/cta/MarketingCtaVideo'; import PollGrid from './cards/poll/PollGrid'; import { PollList } from './cards/poll/PollList'; import { SocialTwitterGrid } from './cards/socialTwitter/SocialTwitterGrid'; @@ -199,11 +190,6 @@ const getTags = ({ AdTag: useListCards ? listAdTag : AdGrid, SquadAdTag: useListCards ? SquadAdList : SquadAdGrid, PlaceholderTag: useListCards ? listPlaceholderTag : PlaceholderGrid, - MarketingCtaTag: useListCards ? MarketingCtaList : MarketingCtaCard, - PlusGridTag: PlusGrid, - AcquisitionFormTag: useListCards - ? AcquisitionFormList - : AcquisitionFormGrid, }; }; @@ -359,15 +345,7 @@ function FeedItemComponent({ ); } - const { - PostTag, - AdTag, - SquadAdTag, - PlaceholderTag, - MarketingCtaTag, - PlusGridTag, - AcquisitionFormTag, - } = getTags({ + const { PostTag, AdTag, SquadAdTag, PlaceholderTag } = getTags({ isListFeedLayout: shouldUseListFeedLayout, shouldUseListMode, postType: getPostTypeForCard( @@ -510,29 +488,6 @@ function FeedItemComponent({ /> ); } - case FeedItemType.UserAcquisition: - return ; - case FeedItemType.MarketingCta: - if (item.marketingCta.variant === MarketingCtaVariant.BriefCard) { - return ; - } - - if (item.marketingCta.variant === MarketingCtaVariant.YearInReview) { - return ; - } - - if (item.marketingCta.variant === MarketingCtaVariant.Video) { - return ; - } - - return ( - - ); - case FeedItemType.PlusEntry: - return ; default: return ; } diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 8c2e7a9eaea..6b863d8fc04 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -333,7 +333,7 @@ export default function MainFeedLayout({ feature: featureFeedChips, shouldEvaluate: !!user && isLaptop && isChipStripPage, }); - const isFeedChipsEnabled = feedChipsVariant === FeedChipsVariant.V2; + const isFeedChipsEnabled = feedChipsVariant !== FeedChipsVariant.None; const showExploreChips = !!user && isLaptop && isChipStripPage && isFeedChipsEnabled; const { feeds } = useFeeds(); @@ -352,10 +352,9 @@ export default function MainFeedLayout({ categories={exploreCategories} isPending={!feeds} compact={isV2} - onNavTabClick={onNavTabClick} /> ) : null, - [showExploreChips, exploreCategories, feeds, isV2, onNavTabClick], + [showExploreChips, exploreCategories, feeds, isV2], ); const { isSearchPageLaptop } = useSearchResultsLayout(); diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 58381d775c6..f57324a9a37 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -4,7 +4,6 @@ import classNames from 'classnames'; import { useRouter } from 'next/router'; import dynamic from 'next/dynamic'; import PromotionalBanner from './PromotionalBanner'; -import { PostOnboardingActivation } from './post/PostOnboardingActivation'; import useSidebarRendered from '../hooks/useSidebarRendered'; import { useLogContext } from '../contexts/LogContext'; import SettingsContext from '../contexts/SettingsContext'; @@ -180,13 +179,33 @@ function MainLayoutComponent({ ? contentTransitionsEnabled : layoutSettled; + const isPageReady = + (growthbook?.ready && router?.isReady && isAuthReady) || isTesting; + + // Everything that isn't feed-shaped (post, tag, source, profile) prerenders + // real data through `getStaticProps`, but `isPageReady` can never be true on + // the server. Unmounting the layout until boot therefore shipped an empty + // `
`, so every crawler that doesn't run JS (including the + // answer engines `PostSEOSchema` targets) saw nothing but meta tags. + // + // Keep variant-specific chrome hidden until boot resolves, while allowing + // the prerendered page content itself to paint immediately. + const isHoldingChrome = !isPageReady && showSidebar; + // On laptop the v1 and v2 chrome (sidebar + global header) look different, // so rendering before the experiment resolves makes v2 users flash the v1 // layout and then swap. Hold the variant-specific chrome until the flag has // resolved so the correct layout paints once. Below laptop there is no v2 // chrome and `isLayoutVariantLoading` never resolves (the flag isn't // evaluated there), so treat non-laptop as always resolved. - const isLayoutChromeResolved = !isLaptop || !isLayoutVariantLoading; + // + // The held render must also stay viewport-independent: `useMedia` seeds its + // state from `window.matchMedia`, so the first client render already knows + // the real breakpoint while the server assumed mobile. Leaving the header to + // `isLaptop` alone made the server emit one and the client skip it, which + // shifted `
` and broke hydration. + const isLayoutChromeResolved = + !isHoldingChrome && (!isLaptop || !isLayoutVariantLoading); // Extension new tab mounts its own `ExtensionTopBanners` strip, so // the webapp strip is suppressed there to avoid duplicate cards. @@ -203,6 +222,15 @@ function MainLayoutComponent({ const sidebarOwnsHeader = isV2 && (isLoggedIn || isExtension) && showSidebar && sidebarRendered; + let stickyHeaderOffset = 'laptop:[--sticky-header-offset:4rem]'; + if (sidebarOwnsHeader) { + stickyHeaderOffset = isBannerAvailable + ? 'laptop:[--sticky-header-offset:2rem]' + : 'laptop:[--sticky-header-offset:0rem]'; + } else if (isBannerAvailable) { + stickyHeaderOffset = 'laptop:[--sticky-header-offset:6rem]'; + } + useEffect(() => { if (!isNotificationsReady || unreadCount === 0 || hasLoggedImpression) { return; @@ -218,8 +246,6 @@ function MainLayoutComponent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNotificationsReady, unreadCount, hasLoggedImpression]); - const isPageReady = - (growthbook?.ready && router?.isReady && isAuthReady) || isTesting; // Feed-shaped pages hold their paint until boot so the resolved chrome // renders once. Broader than the onboarding gate below on purpose: this is // about layout stability, not about forcing onboarding. @@ -279,17 +305,10 @@ function MainLayoutComponent({ }); }, [shouldShowLogin, showLogin]); - // Pages that render the app chrome (sidebar layout) wait for boot before - // painting — the same `isPageReady` gate the feeds already use. The v1/v2 - // chrome differs structurally and the variant only resolves after boot, so - // rendering early makes v2 users paint the v1 layout and then snap. Holding - // until boot lets the resolved layout paint once. The gate is - // breakpoint-independent (false on both server and first client render until - // ready), so it stays free of hydration mismatches. - if ( - (!isPageReady && (isFeedShapedPage || showSidebar)) || - shouldRedirectOnboarding - ) { + // Feed-shaped pages have nothing prerendered worth showing (the feed is + // fetched on the client) and anonymous visitors may still bounce to + // onboarding, so they keep bailing out entirely. + if (shouldRedirectOnboarding || (!isPageReady && isFeedShapedPage)) { return null; } @@ -305,7 +324,6 @@ function MainLayoutComponent({ )} > {canGoBack && } - {customBanner} {isBannerAvailable && } @@ -323,7 +341,10 @@ function MainLayoutComponent({ /> )} - {!sidebarOwnsHeader && isLayoutChromeResolved && ( + {/* Temporary while layout v2 is experimental: production users are on + v1, so render its header in the initial HTML instead of waiting for + feature resolution and delaying the post page's LCP. */} + {!sidebarOwnsHeader && ( {isAuthReady && isLayoutChromeResolved && showSidebar && ( @@ -375,7 +411,9 @@ function MainLayoutComponent({ 'laptop:overflow-clip laptop:rounded-24 laptop:border laptop:border-border-subtlest-quaternary laptop:bg-background-default laptop:p-0.5', !hasTopBanners && !topBanner && - 'laptop:min-h-[calc(100vh-1.5rem)]', + (isBannerAvailable + ? 'laptop:min-h-[calc(100vh-3.5rem)]' + : 'laptop:min-h-[calc(100vh-1.5rem)]'), )} > diff --git a/packages/shared/src/components/UpgradeToPlus.tsx b/packages/shared/src/components/UpgradeToPlus.tsx index 8391f617006..9e2e09019ef 100644 --- a/packages/shared/src/components/UpgradeToPlus.tsx +++ b/packages/shared/src/components/UpgradeToPlus.tsx @@ -8,11 +8,13 @@ import Link from './utilities/Link'; import { plusUrl } from '../lib/constants'; import { useViewSize, ViewSize } from '../hooks'; import { usePlusSubscription } from '../hooks/usePlusSubscription'; +import { usePlusSale } from '../hooks/usePlusSale'; import type { TargetId } from '../lib/log'; import { LogEvent } from '../lib/log'; import { useAuthContext } from '../contexts/AuthContext'; import { AuthTriggers } from '../lib/auth'; import type { WithClassNameProps } from './utilities'; +import { PlusSaleLabel } from './plus/PlusSaleLabel'; type Props = { iconOnly?: boolean; @@ -36,8 +38,10 @@ export const UpgradeToPlus = ({ const isLaptopXL = useViewSize(ViewSize.LaptopXL); const isFullCTAText = !isLaptop || isLaptopXL; const { isPlus, logSubscriptionEvent } = usePlusSubscription(); + const { isActive: isSaleActive } = usePlusSale(); const ctaCopy = { full: 'Get API Access', short: 'API access' }; const content = isFullCTAText ? ctaCopy.full : ctaCopy.short; + const showSaleLabel = isSaleActive && !iconOnly; const defaultColor = ButtonColor.Bacon; const onClick = useCallback( @@ -73,7 +77,14 @@ export const UpgradeToPlus = ({ {...(variant && { variant, color })} {...attrs} > - {iconOnly ? null : content} + {showSaleLabel ? ( + <> + {content} + + + ) : ( + !iconOnly && content + )} ); diff --git a/packages/shared/src/components/auth/SignupWidget.spec.tsx b/packages/shared/src/components/auth/SignupWidget.spec.tsx new file mode 100644 index 00000000000..303bf27858b --- /dev/null +++ b/packages/shared/src/components/auth/SignupWidget.spec.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SignupWidget } from './SignupWidget'; +import { AuthDisplay } from './common'; +import { AuthTriggers } from '../../lib/auth'; +import { useAuthContext } from '../../contexts/AuthContext'; + +jest.mock('../../contexts/AuthContext', () => ({ + ...jest.requireActual('../../contexts/AuthContext'), + useAuthContext: jest.fn(), +})); + +/* The real form is the whole auth stack. What this file is about is the handoff + it makes to the modal, so the mock stands in for the two ways out of it. */ +jest.mock('./AuthOptions', () => ({ + __esModule: true, + default: ({ + trigger, + onAuthStateUpdate, + }: { + trigger: string; + onAuthStateUpdate?: (props: Record) => void; + }) => { + const { AuthDisplay: Display } = jest.requireActual('./common'); + + return ( +
+ {trigger} + + +
+ ); + }, +})); + +const mockUseAuthContext = useAuthContext as jest.MockedFunction< + typeof useAuthContext +>; +const showLogin = jest.fn(); + +beforeEach(() => { + showLogin.mockReset(); + mockUseAuthContext.mockReturnValue({ showLogin } as never); +}); + +const renderWidget = () => + render( + , + ); + +it('hands the inline form the surface that asked for it', () => { + renderWidget(); + + expect(screen.getByTestId('trigger')).toHaveTextContent(AuthTriggers.World); +}); + +it('keeps the surface trigger and the signup screen on the way to the modal', () => { + renderWidget(); + + fireEvent.click(screen.getByText('Continue with email')); + + expect(showLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.World, + options: { + isLogin: false, + defaultDisplay: AuthDisplay.Registration, + formValues: undefined, + }, + }); +}); + +it('carries an existing reader over to the login screen with their email', () => { + renderWidget(); + + fireEvent.click(screen.getByText('Existing email')); + + expect(showLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.World, + options: { + isLogin: true, + defaultDisplay: undefined, + formValues: { email: 'ido@daily.dev' }, + }, + }); +}); diff --git a/packages/shared/src/components/auth/SignupWidget.tsx b/packages/shared/src/components/auth/SignupWidget.tsx index 1d492484ac5..814899db099 100644 --- a/packages/shared/src/components/auth/SignupWidget.tsx +++ b/packages/shared/src/components/auth/SignupWidget.tsx @@ -3,7 +3,6 @@ import React from 'react'; import classNames from 'classnames'; import { useAuthContext } from '../../contexts/AuthContext'; import type { AuthTriggersType } from '../../lib/auth'; -import { AuthTriggers } from '../../lib/auth'; import { ButtonSize, ButtonVariant } from '../buttons/Button'; import AuthOptions from './AuthOptions'; import { AuthDisplay } from './common'; @@ -104,8 +103,12 @@ export function SignupWidget({ forceDefaultDisplay onAuthStateUpdate={(props) => { showLogin({ - trigger: AuthTriggers.Onboarding, - options: { isLogin: true, formValues: props }, + trigger, + options: { + isLogin: !!props.isLoginFlow, + defaultDisplay: props.defaultDisplay, + formValues: props.email ? { email: props.email } : undefined, + }, }); }} onboardingSignupButton={{ diff --git a/packages/shared/src/components/buttons/CardAction.tsx b/packages/shared/src/components/buttons/CardAction.tsx index f5e64df8b7d..732b8410ddc 100644 --- a/packages/shared/src/components/buttons/CardAction.tsx +++ b/packages/shared/src/components/buttons/CardAction.tsx @@ -14,18 +14,22 @@ import { ButtonSize, ButtonVariant, ButtonIconPosition } from './common'; import type { ColorName } from '../../styles/colors'; import InteractionCounter from '../InteractionCounter'; -export type CardActionDensity = 'comfortable' | 'compact'; +export type CardActionDensity = 'comfortable' | 'compact' | 'tight'; const densityToSize: Record = { comfortable: ButtonSize.Medium, compact: ButtonSize.Small, + tight: ButtonSize.XSmall, }; // Larger than buttonSizeToIconSizeV2: engagement-bar icons sit closer // to a 60% ratio (Material 3, Instagram, Reddit) so they read at a glance. -const densityToIconSize: Record = { +// `tight` is the feed-card tier, sized so six actions with counters fit the +// 272px min card width without shrinking. +export const densityToIconSize: Record = { comfortable: IconSize.Small, compact: IconSize.XSmall, + tight: IconSize.Size16, }; type IconElement = React.ReactElement; diff --git a/packages/shared/src/components/buttons/CardActionBar.tsx b/packages/shared/src/components/buttons/CardActionBar.tsx index 6b0a9233a43..c753b65a4bb 100644 --- a/packages/shared/src/components/buttons/CardActionBar.tsx +++ b/packages/shared/src/components/buttons/CardActionBar.tsx @@ -10,7 +10,9 @@ export type CardActionBarLayout = const layoutToClass: Record = { default: 'gap-1', - feedCard: 'flex-1 min-w-0 gap-1 justify-between', + // No `gap`: `justify-between` already spreads the actions, and since buttons + // never shrink a gap only adds width the 272px min card cannot give back. + feedCard: 'flex-1 min-w-0 justify-between', between: 'gap-1 justify-between w-full', compact: 'gap-0.5', }; diff --git a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx index 39cebf4f201..de22714156d 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx @@ -6,7 +6,6 @@ import PostTags from '../common/PostTags'; import PostMetadata from '../common/PostMetadata'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; -import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import { usePostImage } from '../../../hooks/post/usePostImage'; import { useCardCover } from '../../../hooks/feed/useCardCover'; import { stripHtmlTags } from '../../../lib/strings'; @@ -41,7 +40,6 @@ export const FreeformFeaturedWideGridCard = forwardRef( const { pinnedAt } = post; const { title } = useSmartTitle(post); const image = usePostImage(post); - const useGlass = useFeedCardGlassActions(); const significance = post.hero?.significance; const { overlay } = useCardCover({ post, onShare }); const description = useMemo( @@ -54,7 +52,6 @@ export const FreeformFeaturedWideGridCard = forwardRef( ref={ref} post={post} domProps={domProps} - useGlass={useGlass} onPostClick={onPostClick} onPostAuxClick={onPostAuxClick} flagProps={{ pinnedAt }} @@ -68,17 +65,12 @@ export const FreeformFeaturedWideGridCard = forwardRef( )} >
- + -

+

{title}

@@ -102,7 +94,6 @@ export const FreeformFeaturedWideGridCard = forwardRef( - + - {useGlass ? ( - - ) : ( - - )} + {children} diff --git a/packages/shared/src/components/cards/Freeform/FreeformList.tsx b/packages/shared/src/components/cards/Freeform/FreeformList.tsx index 3c21328046f..f8b50c7b266 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformList.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformList.tsx @@ -28,6 +28,10 @@ import { PostType } from '../../../graphql/posts'; import { sanitizeMessage } from '../../../features/onboarding/shared'; import { isSourceUserSource } from '../../../graphql/sources'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; +import { useActiveFeedContext } from '../../../contexts/ActiveFeedContext'; +import { OtherFeedPage } from '../../../lib/query'; +import { SnapshotButton } from '../../imageShare/SnapshotButton'; +import { ButtonVariant } from '../../buttons/Button'; export const FreeformList = forwardRef(function SharePostCard( { @@ -52,6 +56,9 @@ export const FreeformList = forwardRef(function SharePostCard( const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const containerRef = useRef(null); + const cardRef = useRef(null); + const { feedName } = useActiveFeedContext(); + const isWatercooler = feedName === OtherFeedPage.Watercooler; const isFeedPreview = useFeedPreviewMode(); const image = usePostImage(post); const { title } = useSmartTitle(post); @@ -78,6 +85,17 @@ export const FreeformList = forwardRef(function SharePostCard( !!image && 'laptop:mt-auto', )} variant="list" + trailing={ + isWatercooler ? ( + + ) : undefined + } /> ); @@ -143,7 +161,7 @@ export const FreeformList = forwardRef(function SharePostCard( } bookmarked={post.bookmarked} > - + {!isUserSource && post.source && ( ; + snapshotFilename?: string; } export function LeaderboardListItem({ @@ -20,7 +23,9 @@ export function LeaderboardListItem({ className, concatScore = true, onMouseEnter, + snapshotFilename, }: LeaderboardListItemProps): ReactElement { + const rowRef = useRef(null); const formattedNumber = concatScore ? largeNumberFormat(index) : index; const shouldShowTooltip = concatScore && typeof index === 'number' && index >= 1000; @@ -33,11 +38,21 @@ export function LeaderboardListItem({ {children} + {snapshotFilename && ( + + )} ); return ( -
  • +
  • {href ? ( diff --git a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx index 8fe79bb08c2..5025c5eb2b5 100644 --- a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx @@ -66,6 +66,9 @@ export function UserTopList({ TOP_RANK_STYLES[i]?.hoverClass, )} onMouseEnter={TOP_RANK_STYLES[i] ? createRowMouseEnter(i) : undefined} + snapshotFilename={`daily-leaderboard-${ + item.user.username ?? item.user.id + }`} > {showLevel && item.level && ( diff --git a/packages/shared/src/components/cards/ad/AdCard.spec.tsx b/packages/shared/src/components/cards/ad/AdCard.spec.tsx index 1b8a57c4109..bf6f5664fb1 100644 --- a/packages/shared/src/components/cards/ad/AdCard.spec.tsx +++ b/packages/shared/src/components/cards/ad/AdCard.spec.tsx @@ -73,11 +73,12 @@ const getNormalizedText = (element?: Element | null): string => const renderGridComponent = ( props: Partial = {}, + boot: Partial> = {}, ): RenderResult => { const client = new QueryClient(); return render( - + @@ -303,6 +304,34 @@ describe('ad_label experiment', () => { }); }); +// These assert the class list, not the rendered gap: jsdom has no stylesheet to +// compute against. They catch the class being dropped, not a parent rule +// beating it, which is the failure Storybook's Ad Card Fixes page is the real +// check for. +describe('ad card spacing and controls', () => { + it('should keep the disclosure off the ad copy on the grid card', async () => { + renderGridComponent(); + + const attribution = await screen.findByTestId('adAttribution'); + expect(attribution).toHaveClass('mt-3'); + }); + + it('should keep the disclosure off the ad copy on the list card', async () => { + renderListComponent(); + + const attribution = await screen.findByTestId('adAttribution'); + expect(attribution).toHaveClass('mt-3'); + }); + + it('should render the list remove control flat, like the grid card', async () => { + renderListComponent(); + + const remove = await screen.findByRole('link', { name: 'Remove' }); + expect(remove).toHaveClass('btn-tertiary-bacon'); + expect(remove).not.toHaveClass('btn-tertiaryFloat-bacon'); + }); +}); + it('should render advertise link on list ad', () => { renderListComponent(); diff --git a/packages/shared/src/components/cards/ad/AdGrid.tsx b/packages/shared/src/components/cards/ad/AdGrid.tsx index 5507a8a153c..6fba38b2791 100644 --- a/packages/shared/src/components/cards/ad/AdGrid.tsx +++ b/packages/shared/src/components/cards/ad/AdGrid.tsx @@ -10,7 +10,7 @@ import { } from '../common/Card'; import AdLink from './common/AdLink'; import { combinedClicks } from '../../../lib/click'; -import AdAttribution from './common/AdAttribution'; +import AdAttribution, { adAttributionSpacing } from './common/AdAttribution'; import { AdImage } from './common/AdImage'; import { AdPixel } from './common/AdPixel'; import { AdMeasurement } from './common/AdMeasurement'; @@ -29,7 +29,6 @@ import { useFeature } from '../../GrowthBookProvider'; import { adImprovementsV3Feature } from '../../../lib/featureManagement'; import { TargetId } from '../../../lib/log'; import { AdvertiseLink } from './common/AdvertiseLink'; -import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import { useAdLabel } from '../../../features/monetization/useAdLabel'; export const AdGrid = forwardRef(function AdGrid( @@ -38,7 +37,6 @@ export const AdGrid = forwardRef(function AdGrid( ): ReactElement { const { isPlus } = usePlusSubscription(); const adImprovementsV3 = useFeature(adImprovementsV3Feature); - const useGlass = useFeedCardGlassActions(); const { showAdvertiseLink } = useAdLabel(); const { ref } = useAutoRotatingAds( ad, @@ -62,11 +60,12 @@ export const AdGrid = forwardRef(function AdGrid( className="!items-end" /> ) : null} - + - {!useGlass && ( - - )} +
    {!!ad.callToAction && ( @@ -101,13 +100,6 @@ export const AdGrid = forwardRef(function AdGrid(
  • - {useGlass && ( - - )} onViewable?.(ad, data)} /> diff --git a/packages/shared/src/components/cards/ad/AdList.tsx b/packages/shared/src/components/cards/ad/AdList.tsx index 68e6c4390a4..71bc4c23c9d 100644 --- a/packages/shared/src/components/cards/ad/AdList.tsx +++ b/packages/shared/src/components/cards/ad/AdList.tsx @@ -22,7 +22,6 @@ import type { InViewRef } from '../../../hooks/feed/useAutoRotatingAds'; import { useAutoRotatingAds } from '../../../hooks/feed/useAutoRotatingAds'; import { Button } from '../../buttons/Button'; import { ButtonSize, ButtonVariant } from '../../buttons/common'; -import AdAttribution from './common/AdAttribution'; import { AdFavicon } from './common/AdFavicon'; import PostTags from '../common/PostTags'; import { useFeature } from '../../GrowthBookProvider'; @@ -30,6 +29,7 @@ import { adImprovementsV3Feature } from '../../../lib/featureManagement'; import { TargetId } from '../../../lib/log'; import { AdvertiseLink } from './common/AdvertiseLink'; import { useAdLabel } from '../../../features/monetization/useAdLabel'; +import AdAttribution, { adAttributionSpacing } from './common/AdAttribution'; const getLinkProps = ({ ad, @@ -87,7 +87,7 @@ export const AdList = forwardRef(function AdCard( ) : null} @@ -117,6 +117,7 @@ export const AdList = forwardRef(function AdCard(
    {!isPlus && ( diff --git a/packages/shared/src/components/cards/ad/common/AdAttribution.tsx b/packages/shared/src/components/cards/ad/common/AdAttribution.tsx index 819ea1bf4c8..0a31f410e9d 100644 --- a/packages/shared/src/components/cards/ad/common/AdAttribution.tsx +++ b/packages/shared/src/components/cards/ad/common/AdAttribution.tsx @@ -5,6 +5,13 @@ import type { Ad } from '../../../../graphql/posts'; import { useScrambler } from '../../../../hooks/useScrambler'; import { useAdLabel } from '../../../../features/monetization/useAdLabel'; +/** + * Minimum room between the ad copy and the disclosure line. The grid card + * pushes the disclosure down with a flex spacer, which collapses to nothing on + * a long creative and leaves the line touching the title. + */ +export const adAttributionSpacing = 'mt-3'; + interface AdClassName { main?: string; typo?: string; @@ -38,6 +45,7 @@ export default function AdAttribution({ target="_blank" rel="noopener" className={elementClass} + data-testid="adAttribution" suppressHydrationWarning > {promotedText} @@ -46,7 +54,11 @@ export default function AdAttribution({ } return ( -
    +
    {promotedText}
    ); diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx index 25da164ed0a..25b111db9b6 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx @@ -11,10 +11,7 @@ import type { Post } from '../../../graphql/posts'; import type { PostHero, PostHeroSignificance } from '../../../graphql/types'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; import { ArticleFeaturedWideGridCard } from './ArticleFeaturedWideGridCard'; -import { - featureFeedCardGlassActions, - featureHeroCards, -} from '../../../lib/featureManagement'; +import { featureHeroCards } from '../../../lib/featureManagement'; jest.mock('next/router', () => ({ useRouter: jest.fn(), @@ -110,53 +107,3 @@ it('renders no chip when post has no highlight', () => { expect(screen.queryByText('Major')).not.toBeInTheDocument(); expect(screen.queryByText('Notable')).not.toBeInTheDocument(); }); - -const renderGlassHero = (postOverride: Partial): RenderResult => { - const gb = new GrowthBook(); - gb.setFeatures({ - [featureFeedCardGlassActions.id]: { defaultValue: true }, - [featureHeroCards.id]: { - defaultValue: { ...featureHeroCards.defaultValue, enabled: true }, - }, - }); - return render( - - - , - ); -}; - -// In glass mode the action pill floats over the bottom of the content column, -// so a full-size 3-line title + 3-line TLDR overflowed and the TLDR's last line -// was cut off behind the pill. The title keeps up to 3 lines but drops one type -// step (typo-title2, still larger than the default card's typo-title3) when a -// TLDR is present so both fit and all three TLDR lines stay visible. -it('shrinks the glass hero title to typo-title2 (still 3 lines) when a TLDR is present', () => { - renderGlassHero({ summary: 'A concise summary of the article.' }); - const heading = screen.getByRole('heading', { level: 3 }); - expect(heading).toHaveClass('typo-title2'); - expect(heading).toHaveClass('line-clamp-3'); - expect(heading).not.toHaveClass('typo-title1'); -}); - -it('keeps the full-size glass hero title (typo-title1) when there is no TLDR', () => { - renderGlassHero({ summary: '', sharedPost: undefined }); - const heading = screen.getByRole('heading', { level: 3 }); - expect(heading).toHaveClass('typo-title1'); - expect(heading).toHaveClass('line-clamp-3'); -}); - -it('insets the clipped glass text column with padding so the chip glow is not clipped', () => { - renderGlassHero({ hero: makeHero('major') }); - - const chip = screen.getByText('Major').parentElement!; - expect(chip.firstElementChild).toHaveClass('breaking-news-chip-glow'); - - const textColumn = chip.closest('.overflow-hidden.flex-1'); - expect(textColumn).toHaveClass('px-4'); - expect(textColumn).not.toHaveClass('mx-4'); -}); diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx index 3fc5607664c..5e2b309e09c 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -10,7 +10,6 @@ import PostMetadata from '../common/PostMetadata'; import { FeedbackGrid } from './feedback/FeedbackGrid'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; -import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import { usePostImage } from '../../../hooks/post/usePostImage'; import { useCardCover } from '../../../hooks/feed/useCardCover'; import { stripHtmlTags } from '../../../lib/strings'; @@ -49,10 +48,6 @@ export const ArticleFeaturedWideGridCard = forwardRef( const isVideoType = isVideoPost(post); const image = usePostImage(post); const { overlay } = useCardCover({ post, onShare }); - const glassActions = useFeedCardGlassActions(); - // The hero keeps the pill on the content column (where its bar already sat), - // not over the cover image. - const useGlass = glassActions && !showFeedback; const significance = post.hero?.significance; const isTweetPost = post.type === PostType.SocialTwitter || @@ -86,16 +81,6 @@ export const ArticleFeaturedWideGridCard = forwardRef( post.summary, ]); - // In glass mode the action pill floats over the bottom of the content - // column, so a full-size 3-line title + 3-line TLDR overflow the clipped - // area and the TLDR's last line gets cut off behind the pill. Keep the title - // at up to 3 lines (the title text matters) and instead drop it one type - // step — typo-title2, still larger than the default card's typo-title3 — when - // a TLDR is present, so both fit and all three TLDR lines stay visible. - const titleClampClass = useGlass ? 'line-clamp-3' : 'line-clamp-4'; - const titleSizeClass = - useGlass && description ? 'typo-title2' : 'typo-title1'; - const feedbackContent = ( <>

    @@ -112,7 +97,7 @@ export const ArticleFeaturedWideGridCard = forwardRef( const standardContent = ( <> - + -

    +

    {title}

    @@ -146,19 +122,13 @@ export const ArticleFeaturedWideGridCard = forwardRef( className="mt-1" /> {!!description && ( -

    +

    {description}

    )} ({ useRouter: jest.fn(), @@ -178,24 +176,3 @@ it('should show cover image with play icon when post is video:youtube type', asy const image = await screen.findByTestId('playIconVideoPost'); expect(image).toBeInTheDocument(); }); - -// The floating action bar's per-theme fill and its re-toned pressed accents live -// in `styles/components/feedCardGlassActions.css`, keyed entirely off this class -// (the two themes need different values, so they can't be Tailwind utilities). -// Drop the class and the bar silently reverts to the inaccessible 64% fill with -// ghost-ladder accents — 1.0:1 to 2.9:1 in light mode — with nothing else -// failing, so pin the hook-up here. -it('renders the glass action bar with the class that carries its theme styling', async () => { - const gb = new GrowthBook(); - gb.setFeatures({ - [featureFeedCardGlassActions.id]: { defaultValue: true }, - }); - render( - - - , - ); - - const copyLink = await screen.findByLabelText('Copy link'); - expect(copyLink.closest('.feed-card-glass-actions')).not.toBeNull(); -}); diff --git a/packages/shared/src/components/cards/article/ArticleGrid.tsx b/packages/shared/src/components/cards/article/ArticleGrid.tsx index 545eebcc04a..4abb8fcd9c2 100644 --- a/packages/shared/src/components/cards/article/ArticleGrid.tsx +++ b/packages/shared/src/components/cards/article/ArticleGrid.tsx @@ -23,14 +23,9 @@ import PostTags from '../common/PostTags'; import PostMetadata from '../common/PostMetadata'; import { PostCardFooter } from '../common/PostCardFooter'; import ActionButtons from '../common/ActionButtons'; -import { - FeedCardGlassActions, - glassCoverImageClassName, -} from '../common/FeedCardGlassActions'; import { FeedbackGrid } from './feedback/FeedbackGrid'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; -import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; export const ArticleGrid = forwardRef(function ArticleGrid( { @@ -60,7 +55,6 @@ export const ArticleGrid = forwardRef(function ArticleGrid( const { showFeedback } = usePostFeedback({ post }); const { title } = useSmartTitle(post); const isVideoType = isVideoPost(post); - const glassActions = useFeedCardGlassActions(); if (isHidden) { return ( @@ -97,7 +91,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( className: getPostClassNames( post, classNames(className, showFeedback && '!p-0'), - glassActions && !showFeedback ? 'min-h-cardGlass' : 'min-h-card', + 'min-h-card', ), }} ref={ref} @@ -156,41 +150,27 @@ export const ArticleGrid = forwardRef(function ArticleGrid( /> )} - + - {!showFeedback && - (glassActions ? ( - - ) : ( - - ))} + {!showFeedback && ( + + )}
    {children} diff --git a/packages/shared/src/components/cards/article/ArticleList.spec.tsx b/packages/shared/src/components/cards/article/ArticleList.spec.tsx new file mode 100644 index 00000000000..b81071738c8 --- /dev/null +++ b/packages/shared/src/components/cards/article/ArticleList.spec.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import post from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { mockDesktop } from '../../../../__tests__/helpers/media'; +import { ArticleList } from './ArticleList'; + +jest.mock('next/router', () => ({ useRouter: jest.fn() })); + +const renderCard = (isNarrow?: boolean) => + render( + + + , + ); + +beforeEach(() => { + jest.clearAllMocks(); + mockDesktop(); + jest + .mocked(useRouter) + .mockImplementation(() => ({ pathname: '/' } as unknown as NextRouter)); +}); + +describe('ArticleList in a narrow column', () => { + it('stacks the cover under the title', () => { + renderCard(true); + + // Found by alt text: the testid sits on the wrapper, not on the image. + const cover = screen.getByAltText('Post Cover image'); + + expect(cover).toHaveClass('!w-full'); + expect(cover).toHaveClass('self-stretch'); + }); + + it('drops the gutter that separated the title from the cover beside it', () => { + renderCard(true); + + expect(screen.getByText(post.title as string).closest('.mr-4')).toBeNull(); + }); + + it('leaves the wide card exactly as it was', () => { + renderCard(); + + const cover = screen.getByAltText('Post Cover image'); + + expect(cover).not.toHaveClass('!w-full'); + expect( + screen.getByText(post.title as string).closest('.mr-4'), + ).not.toBeNull(); + }); +}); diff --git a/packages/shared/src/components/cards/article/ArticleList.tsx b/packages/shared/src/components/cards/article/ArticleList.tsx index afeaa5c459d..1d99abf8fc1 100644 --- a/packages/shared/src/components/cards/article/ArticleList.tsx +++ b/packages/shared/src/components/cards/article/ArticleList.tsx @@ -45,7 +45,14 @@ export const ArticleList = forwardRef(function ArticleList( domProps = {}, onShare, eagerLoadImage = false, - }: PostCardProps, + isNarrow = false, + }: PostCardProps & { + /** + * Takes the phone's stacked layout at any viewport, for a card in a dragged + * column the window's breakpoints know nothing about. + */ + isNarrow?: boolean; + }, ref: Ref, ): ReactElement { const { className, style } = domProps; @@ -55,6 +62,7 @@ export const ArticleList = forwardRef(function ArticleList( const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); + const isStacked = isMobile || isNarrow; const { showFeedback } = usePostFeedback({ post }); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const isFeedPreview = useFeedPreviewMode(); @@ -165,8 +173,13 @@ export const ArticleList = forwardRef(function ArticleList( )}
    - -
    + +
    - {!isMobile && actionButtons} + {!isStacked && actionButtons}
    - {isMobile && actionButtons} + {isStacked && actionButtons} {children} )} diff --git a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx index 371fbbb5c37..e75a084a0de 100644 --- a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx @@ -7,7 +7,6 @@ import PostTags from '../common/PostTags'; import PostMetadata from '../common/PostMetadata'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; -import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import { usePostImage } from '../../../hooks/post/usePostImage'; import { useCardCover } from '../../../hooks/feed/useCardCover'; import { CollectionCardHeader } from './CollectionCardHeader'; @@ -41,7 +40,6 @@ export const CollectionFeaturedWideGridCard = forwardRef( const { pinnedAt } = post; const { title } = useSmartTitle(post); const image = usePostImage(post); - const useGlass = useFeedCardGlassActions(); const significance = post.hero?.significance; const wasUpdated = isPostUpdated(post); const { overlay } = useCardCover({ post, onShare }); @@ -51,7 +49,6 @@ export const CollectionFeaturedWideGridCard = forwardRef( ref={ref} post={post} domProps={domProps} - useGlass={useGlass} onPostClick={onPostClick} onPostAuxClick={onPostAuxClick} flagProps={{ pinnedAt }} @@ -65,14 +62,9 @@ export const CollectionFeaturedWideGridCard = forwardRef( )} >
    - + -

    +

    {title}

    @@ -100,7 +92,6 @@ export const CollectionFeaturedWideGridCard = forwardRef( onPostClick?.(post); const onPostCardAuxClick = () => onPostAuxClick?.(post); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); - const useGlass = useFeedCardGlassActions(); if (isHidden) { return ( @@ -87,7 +81,7 @@ export const CollectionGrid = forwardRef(function CollectionCard( className: getPostClassNames( post, domProps.className ?? '', - useGlass ? 'min-h-cardGlass' : 'min-h-card', + 'min-h-card', ), }} ref={ref} @@ -120,16 +114,12 @@ export const CollectionGrid = forwardRef(function CollectionCard( {postMetadata} - + - {useGlass ? ( - - ) : ( - - )} + {children} diff --git a/packages/shared/src/components/cards/common/ActionButtons.spec.tsx b/packages/shared/src/components/cards/common/ActionButtons.spec.tsx new file mode 100644 index 00000000000..96af6772532 --- /dev/null +++ b/packages/shared/src/components/cards/common/ActionButtons.spec.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import ActionButtons from './ActionButtons'; +import type { ActionButtonsVariant } from './ActionButtons'; +import post from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; +import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2'; +import { useViewSize } from '../../../hooks/useViewSize'; + +jest.mock('../../../hooks/post/usePostImpressions', () => ({ + usePostImpressions: jest.fn(), +})); + +// jsdom reports every media query as unmatched, so the viewport is forced: +// the award gate must behave the same on both sides of the laptop breakpoint. +jest.mock('../../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../../hooks/useViewSize'), + useViewSize: jest.fn(), +})); + +jest.mock('../../../hooks/post/usePostImpressionsModal', () => ({ + usePostImpressionsModal: () => jest.fn(), +})); + +jest.mock('../../../hooks/useEngagementBarV2', () => ({ + useEngagementBarV2: jest.fn(), +})); + +jest.mock('../../post/PostAwardAction', () => ({ + __esModule: true, + default: () =>
    , +})); + +const mockImpressions = (enabled: boolean) => + jest.mocked(usePostImpressions).mockReturnValue({ + enabled, + showImpressions: enabled, + impressions: enabled ? 1000 : 0, + }); + +const renderComponent = (variant: ActionButtonsVariant) => + render( + + + , + ); + +const variants: ActionButtonsVariant[] = ['grid', 'list', 'signal']; + +describe.each([ + [false, false], + [false, true], + [true, false], + [true, true], +])('ActionButtons (v2: %s, laptop: %s)', (isV2, isLaptop) => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useEngagementBarV2).mockReturnValue(isV2); + jest.mocked(useViewSize).mockReturnValue(isLaptop); + }); + + it.each(variants)( + 'hides the award action on a %s card when impressions are enabled', + (variant) => { + mockImpressions(true); + + renderComponent(variant); + + expect(screen.queryByTestId('award-action')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Impressions' }), + ).toBeInTheDocument(); + }, + ); + + it.each(variants)( + 'keeps the award action on a %s card when impressions are disabled', + (variant) => { + mockImpressions(false); + + renderComponent(variant); + + expect(screen.getByTestId('award-action')).toBeInTheDocument(); + }, + ); +}); diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 84dfd0ac500..328e20c4273 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; @@ -10,17 +10,22 @@ import { LinkIcon, DownvoteIcon, } from '../../icons'; -import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; +import { ButtonColor, ButtonVariant } from '../../buttons/Button'; +import { useFeedPreviewMode } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons'; -import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import PostAwardAction from '../../post/PostAwardAction'; import ConditionalWrapper from '../../ConditionalWrapper'; import { PostTagsPanel } from '../../post/block/PostTagsPanel'; import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip'; import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { + actionCounterClassName as counterClassName, + actionCounterLabelClassName as counterLabelClassName, + FEED_ACTION_BUTTON_SIZE, + FEED_ACTION_ICON_SIZE, +} from './actionCounter'; import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal'; import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; @@ -41,26 +46,30 @@ export interface ActionButtonsProps { variant?: ActionButtonsVariant; showDownvoteAction?: boolean; showAwardAction?: boolean; + /** Rendered in the trailing group, right after the bookmark button. */ + trailing?: ReactNode; } const variantConfig = { grid: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, - containerClassName: 'px-1 pb-1', + buttonSize: FEED_ACTION_BUTTON_SIZE, + iconSize: FEED_ACTION_ICON_SIZE, + // Asymmetric: an icon sits on the left edge and the impressions number on + // the right, which needs more room to look optically centred. + containerClassName: 'py-1.5 pl-1 pr-2.5', showTagsPanel: false, useCommentLink: false, }, list: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, + buttonSize: FEED_ACTION_BUTTON_SIZE, + iconSize: FEED_ACTION_ICON_SIZE, containerClassName: '', showTagsPanel: true, useCommentLink: true, }, signal: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, + buttonSize: FEED_ACTION_BUTTON_SIZE, + iconSize: FEED_ACTION_ICON_SIZE, containerClassName: '', showTagsPanel: false, useCommentLink: true, @@ -78,17 +87,11 @@ const ActionButtonsV1 = ({ variant = 'grid', showDownvoteAction = true, showAwardAction = true, + trailing, }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - const isLaptop = useViewSize(ViewSize.Laptop); const { buttonSize, iconSize } = config; - // On mobile/tablet keep full-size icons but shrink the count so the icon - // reads as the primary affordance and the number as a subtle stat. - const counterClassName = classNames( - 'tabular-nums', - isLaptop ? variant === 'grid' && 'typo-footnote' : 'typo-caption1', - ); const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -145,7 +148,7 @@ const ActionButtonsV1 = ({ href={post.commentsPermalink} > } pressed={post.commented} @@ -206,7 +209,7 @@ const ActionButtonsV1 = ({ side={variant === 'grid' ? 'bottom' : undefined} > )} - {/* When impressions are enabled, drop awards below laptop to make room - for the extra action; with the flag off, awards stay on every - viewport (unchanged from control). */} - {showAwardAction && (!impressionsEnabled || isLaptop) && ( - + {showAwardAction && !impressionsEnabled && ( + )} + {trailing} } diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 38a382a8388..fd9197cbe21 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -11,7 +11,7 @@ import { DownvoteIcon, } from '../../icons'; import { ButtonColor } from '../../buttons/ButtonV2'; -import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; +import { useFeedPreviewMode } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons/BookmarkButton.v2'; import { Tooltip } from '../../tooltip/Tooltip'; @@ -39,11 +39,13 @@ export interface ActionButtonsProps { showAwardAction?: boolean; } -const FEED_CARD_DENSITY = 'compact'; +const FEED_CARD_DENSITY = 'tight'; const variantConfig = { grid: { - containerClassName: 'px-1 pb-1', + // Matches the v1 bar: `py-1.5` holds the row at 36px around the h-6 + // buttons, and the wider right edge gives the trailing number room. + containerClassName: 'py-1.5 pl-1 pr-2.5', showTagsPanel: false, useCommentLink: false, }, @@ -73,9 +75,6 @@ const ActionButtons = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - // When impressions are enabled, awards are hidden below laptop (tablet + - // mobile) to make room for the extra action. - const isLaptop = useViewSize(ViewSize.Laptop); const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -207,7 +206,7 @@ const ActionButtons = ({ /> )} - {showAwardAction && (!impressionsEnabled || isLaptop) && ( + {showAwardAction && !impressionsEnabled && ( )} void; onCopy: () => void; post: Post; - className?: string; } export function CardCoverShare({ post, onCopy, onShare, - className, }: CardCoverShareProps): ReactElement { const { onCopyLink, isLoading } = useLoggedCopyPostLink(post); const onClick = () => { @@ -28,10 +26,7 @@ export function CardCoverShare({ }; return ( - +
    diff --git a/packages/shared/src/components/dropdown/DropdownMenu.tsx b/packages/shared/src/components/dropdown/DropdownMenu.tsx index 66db094f81c..d45ce34832e 100644 --- a/packages/shared/src/components/dropdown/DropdownMenu.tsx +++ b/packages/shared/src/components/dropdown/DropdownMenu.tsx @@ -200,7 +200,11 @@ export const DropdownMenuOptions = ({ role="menuitem" {...anchorProps} > -
    + {icon} {label} diff --git a/packages/shared/src/components/feeds/ExploreChipsBar.spec.tsx b/packages/shared/src/components/feeds/ExploreChipsBar.spec.tsx index 1ae61152d3d..be7ee632330 100644 --- a/packages/shared/src/components/feeds/ExploreChipsBar.spec.tsx +++ b/packages/shared/src/components/feeds/ExploreChipsBar.spec.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { useRouter } from 'next/router'; import { useAuthContext } from '../../contexts/AuthContext'; import { useLogContext } from '../../contexts/LogContext'; import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; -import { useDailyPage } from '../../hooks/feed/useDailyPage'; import { ExploreChipsBar } from './ExploreChipsBar'; import type { ExploreCategory } from './exploreCategories'; @@ -30,14 +29,6 @@ jest.mock('../../hooks/feed/useCustomDefaultFeed', () => ({ default: jest.fn(), })); -jest.mock('../../hooks/feed/useDailyPage', () => ({ - useDailyPage: jest.fn(), -})); - -jest.mock('../../features/daily/DailySwitcher', () => ({ - DailySwitcher: () =>
    , -})); - jest.mock('./NewStripCta', () => ({ NewStripCta: ({ className }: { className?: string }) => ( @@ -54,7 +45,6 @@ const mockUseRouter = useRouter as jest.Mock; const mockUseAuthContext = useAuthContext as jest.Mock; const mockUseLogContext = useLogContext as jest.Mock; const mockUseCustomDefaultFeed = useCustomDefaultFeed as jest.Mock; -const mockUseDailyPage = useDailyPage as jest.Mock; const scrollIntoView = jest.fn(); @@ -86,7 +76,22 @@ describe('ExploreChipsBar', () => { mockUseAuthContext.mockReturnValue({ isLoggedIn: true }); mockUseLogContext.mockReturnValue({ logEvent: jest.fn() }); mockUseCustomDefaultFeed.mockReturnValue({ isCustomDefaultFeed: false }); - mockUseDailyPage.mockReturnValue({ isEnabled: false }); + }); + + it('renders the For you category ahead of the given categories', () => { + mockRouterPath('/'); + + render(); + + const labels = screen.getAllByRole('link').map((link) => link.textContent); + expect(labels.indexOf('For you')).toBeLessThan( + labels.indexOf('JavaScript'), + ); + expect(labels.indexOf('JavaScript')).toBeLessThan(labels.indexOf('React')); + expect(screen.getByRole('link', { name: 'For you' })).toHaveAttribute( + 'aria-current', + 'page', + ); }); it('centers only when the active category identity changes', async () => { diff --git a/packages/shared/src/components/feeds/ExploreChipsBar.tsx b/packages/shared/src/components/feeds/ExploreChipsBar.tsx index 43a69cb4bcd..43e7757ec59 100644 --- a/packages/shared/src/components/feeds/ExploreChipsBar.tsx +++ b/packages/shared/src/components/feeds/ExploreChipsBar.tsx @@ -6,18 +6,16 @@ import Link from '../utilities/Link'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { PlusIcon } from '../icons'; import { webappUrl } from '../../lib/constants'; -import { isExtension } from '../../lib/func'; -import { SharedFeedPage } from '../utilities'; import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; import { ElementPlaceholder } from '../ElementPlaceholder'; import { useLogContext } from '../../contexts/LogContext'; import { useAuthContext } from '../../contexts/AuthContext'; -import { useDailyPage } from '../../hooks/feed/useDailyPage'; -import { DailySwitcher } from '../../features/daily/DailySwitcher'; import type { ExploreCategory } from './exploreCategories'; import { findActiveChipId } from './exploreCategories'; import { LogEvent } from '../../lib/log'; import { NewStripCta } from './NewStripCta'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureFeedChips } from '../../lib/featureManagement'; interface ExploreChipsBarProps { categories: ExploreCategory[]; @@ -27,7 +25,6 @@ interface ExploreChipsBarProps { // (text + active Float background + bottom-border underline) so the chips // header matches the canonical tabbed page header. Off → legacy pills. compact?: boolean; - onNavTabClick?: (tab: string) => void; } const PLACEHOLDER_WIDTHS = ['w-20', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']; @@ -39,20 +36,17 @@ export function ExploreChipsBar({ isPending, className, compact, - onNavTabClick, }: ExploreChipsBarProps): ReactElement | null { const router = useRouter(); const { isCustomDefaultFeed } = useCustomDefaultFeed(); const { logEvent } = useLogContext(); const { isLoggedIn } = useAuthContext(); - const { isEnabled } = useDailyPage(); - const showDailySwitcher = isLoggedIn && isEnabled; - - const onFeedClick = - isExtension && onNavTabClick - ? () => onNavTabClick(isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/') - : undefined; - + const { value: variant, isLoading: isVariantLoading } = useConditionalFeature( + { + feature: featureFeedChips, + shouldEvaluate: isLoggedIn, + }, + ); const forYouCategory: ExploreCategory = useMemo(() => { const path = isCustomDefaultFeed ? `${webappUrl}my-feed` : webappUrl; return { @@ -69,8 +63,8 @@ export function ExploreChipsBar({ }, [isCustomDefaultFeed]); const allCategories = useMemo( - () => (showDailySwitcher ? categories : [forYouCategory, ...categories]), - [showDailySwitcher, forYouCategory, categories], + () => [forYouCategory, ...categories], + [forYouCategory, categories], ); const activeId = useMemo( @@ -108,9 +102,6 @@ export function ExploreChipsBar({ ref={scrollRef} className="no-scrollbar flex items-center gap-2 overflow-x-auto pr-12" > - {showDailySwitcher && ( - - )} @@ -124,6 +115,10 @@ export function ExploreChipsBar({ logEvent({ event_name: LogEvent.ClickFeedTagChip, target_id: category.tag, + extra: JSON.stringify({ + variant: isVariantLoading ? undefined : variant, + origin: category.origin, + }), }); }; diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index 8bfc0cf0e54..ec90b7c70b9 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -28,10 +28,15 @@ import { uploadCvBgTablet, uploadCvBgMobile, } from '../../lib/image'; -import { useUploadCv } from '../../features/profile/hooks/useUploadCv'; +import { + uploadCvOpportunitySuccessContent, + uploadCvProfileSuccessContent, + useUploadCv, +} from '../../features/profile/hooks/useUploadCv'; import { TargetId } from '../../lib/log'; import { useHasIntroQuests } from '../../hooks/useHasIntroQuests'; import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; +import { useJobsFeature } from '../../hooks/useJobsFeature'; export interface FeedContainerProps { children: ReactNode; @@ -45,7 +50,7 @@ export interface FeedContainerProps { actionButtons?: ReactNode; isHorizontal?: boolean; feedContainerRef?: React.Ref; - showBriefCard?: boolean; + hasFirstSlotCard?: boolean; disableListFrame?: boolean; } @@ -131,7 +136,7 @@ export const FeedContainer = ({ actionButtons, isHorizontal, feedContainerRef, - showBriefCard, + hasFirstSlotCard, disableListFrame = false, }: FeedContainerProps): ReactElement => { const currentSettings = useContext(FeedContext); @@ -190,7 +195,11 @@ export const FeedContainer = ({ const { getMarketingCta, clearMarketingCta } = useBoot(); const marketingCta = getMarketingCta(MarketingCtaVariant.FeedBanner); + const { isJobsEnabled } = useJobsFeature(); const { onUpload, status, shouldShow } = useUploadCv({ + modalContent: isJobsEnabled + ? uploadCvOpportunitySuccessContent + : uploadCvProfileSuccessContent, onUploadSuccess: () => { if (marketingCta) { clearMarketingCta(marketingCta.campaignId); @@ -203,6 +212,27 @@ export const FeedContainer = ({ shouldEvaluate: shouldEvaluateBanner, }); const shouldShowBanner = shouldEvaluateBanner && !hasIntroQuests; + let uploadCvBannerTitle = 'Complete your profile faster'; + let uploadCvBannerDescription = + 'Upload your CV to import your experience, skills, and education. You can review and edit everything after.'; + + if (isJobsEnabled) { + uploadCvBannerTitle = + marketingCta?.flags?.title || 'Your next job should apply to you'; + uploadCvBannerDescription = + marketingCta?.flags?.description || + 'Upload your CV so we quietly match you with roles you might actually want. Nothing is shared without your ok.'; + } + + const uploadCvBanner = { + title: uploadCvBannerTitle, + description: uploadCvBannerDescription, + cover: { + laptop: isList ? uploadCvBgTablet : uploadCvBgLaptop, + tablet: uploadCvBgTablet, + base: uploadCvBgMobile, + }, + }; const clearMarketingCtaRef = useRef(clearMarketingCta); clearMarketingCtaRef.current = clearMarketingCta; @@ -230,14 +260,14 @@ export const FeedContainer = ({
    marketingCta && clearMarketingCta(marketingCta.campaignId) } - banner={ - marketingCta?.flags?.title && marketingCta?.flags?.description - ? { - title: marketingCta.flags.title, - description: marketingCta.flags.description, - cover: { - laptop: isList ? uploadCvBgTablet : uploadCvBgLaptop, - tablet: uploadCvBgTablet, - base: uploadCvBgMobile, - }, - } - : undefined - } + banner={uploadCvBanner} targetId={TargetId.Feed} />
    diff --git a/packages/shared/src/components/feeds/FeedNav.spec.tsx b/packages/shared/src/components/feeds/FeedNav.spec.tsx index da6b8746234..f4d5d3eba13 100644 --- a/packages/shared/src/components/feeds/FeedNav.spec.tsx +++ b/packages/shared/src/components/feeds/FeedNav.spec.tsx @@ -120,10 +120,6 @@ jest.mock('../notifications/NotificationsBell', () => ({ default: () =>
    , })); -jest.mock('../../features/giveback/components/GivebackGiftEntry', () => ({ - GivebackGiftEntry: () =>
    , -})); - jest.mock('../marketing/banners/PlusMobileEntryBanner', () => ({ __esModule: true, default: () =>
    , @@ -274,15 +270,12 @@ describe('FeedNav', () => { render(); const chipsScrollContainer = screen.getByTestId('chips-scroll-container'); - const givebackEntry = screen.getByTestId('giveback-entry'); const notificationsBell = screen.getByTestId('notifications-bell'); expect( screen.queryByRole('button', { name: 'Feed settings' }), ).not.toBeInTheDocument(); - expect(screen.getAllByTestId('giveback-entry')).toHaveLength(1); expect(screen.getAllByTestId('notifications-bell')).toHaveLength(1); - expect(chipsScrollContainer.parentElement).toContainElement(givebackEntry); expect(chipsScrollContainer.parentElement).toContainElement( notificationsBell, ); @@ -310,7 +303,7 @@ describe('FeedNav', () => { 'pr-28', ); expect(screen.getByRole('menuitem', { name: 'Leaderboard' })).toHaveClass( - 'tablet:last-of-type:mr-24', + 'tablet:last-of-type:mr-12', ); expect(stickyActions).toHaveClass( 'sticky', diff --git a/packages/shared/src/components/feeds/FeedNav.tsx b/packages/shared/src/components/feeds/FeedNav.tsx index 2da3b14ec2d..02273b192f8 100644 --- a/packages/shared/src/components/feeds/FeedNav.tsx +++ b/packages/shared/src/components/feeds/FeedNav.tsx @@ -28,7 +28,6 @@ import { useScrollTopClassName } from '../../hooks/useScrollTopClassName'; import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { webappUrl } from '../../lib/constants'; import NotificationsBell from '../notifications/NotificationsBell'; -import { GivebackGiftEntry } from '../../features/giveback/components/GivebackGiftEntry'; import classed from '../../lib/classed'; import type { AllFeedPages } from '../../lib/query'; import { OtherFeedPage } from '../../lib/query'; @@ -72,15 +71,15 @@ function FeedNav(): ReactElement | null { const { home, bookmarks } = useActiveNav(feedName); const isMobile = useViewSize(ViewSize.MobileL); const isBelowLaptop = !useViewSize(ViewSize.Laptop); - // Phones get the giveback entry via MobileFeedActions and laptop+ via the - // header/rail; the tablet feed header is the only gap, so it renders its own. - // JS-gated (not CSS) so it never mounts alongside the other placements. + // The notifications bell only belongs to the tablet feed header: phones get it + // from the footer nav and laptop+ from the app header. JS-gated (not CSS) so + // it never mounts alongside those placements. const isTablet = isBelowLaptop && !isMobile; const { value: feedChipsVariant } = useConditionalFeature({ feature: featureFeedChips, shouldEvaluate: isBelowLaptop, }); - const isFeedChipsEnabled = feedChipsVariant === FeedChipsVariant.V2; + const isFeedChipsEnabled = feedChipsVariant !== FeedChipsVariant.None; const [selectedAlgo, setSelectedAlgo] = usePersistentContext( DEFAULT_ALGORITHM_KEY, DEFAULT_ALGORITHM_INDEX, @@ -95,12 +94,10 @@ function FeedNav(): ReactElement | null { const isForYouTab = router.pathname === webappUrl || router.pathname === `${webappUrl}my-feed`; const { plusEntryForYou } = usePlusEntry(); - const isDailyPage = router.pathname === '/daily'; const showFeedActions = isMobile && ((sortingEnabled && isSortableFeed) || feedName === SharedFeedPage.Custom); - const shouldRenderFeedChips = - (isBelowLaptop && isFeedChipsEnabled) || isDailyPage; + const shouldRenderFeedChips = isBelowLaptop && isFeedChipsEnabled; const renderFeedActions = (iconOnly?: boolean) => ( <> @@ -175,7 +172,7 @@ function FeedNav(): ReactElement | null { isCustomDefaultFeed, ]); - const shouldRenderNav = home || isDailyPage || (isMobile && bookmarks); + const shouldRenderNav = home || (isMobile && bookmarks); if (!shouldRenderNav || router?.pathname?.startsWith('/posts/[id]')) { return null; } @@ -204,8 +201,7 @@ function FeedNav(): ReactElement | null { )} {isTablet && ( -
    - +
    )} @@ -223,7 +219,7 @@ function FeedNav(): ReactElement | null { tabListProps={{ className: { indicator: '!w-6', - item: 'px-1 tablet:last-of-type:mr-24', + item: 'px-1 tablet:last-of-type:mr-12', }, autoScrollActive: true, }} @@ -256,8 +252,7 @@ function FeedNav(): ReactElement | null { )} {!shouldRenderFeedChips && ( -
    - {isTablet && } +
    )} diff --git a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx index 769821a76fe..1124d0b3275 100644 --- a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx @@ -100,7 +100,7 @@ export const FeedSettingsEditHeader = (): ReactElement | null => { feature: featureFeedChips, shouldEvaluate: !isPlus, }); - const isFeedChipsEnabled = feedChipsVariant === FeedChipsVariant.V2; + const isFeedChipsEnabled = feedChipsVariant !== FeedChipsVariant.None; const { value: { full: plusCta }, } = useConditionalFeature({ diff --git a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx index 1b27c48c4ec..2fdaf6c3403 100644 --- a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx @@ -65,7 +65,7 @@ export const useFeedSettingsEdit = ({ feature: featureFeedChips, shouldEvaluate: !isPlus, }); - const isFeedChipsEnabled = feedChipsVariant === FeedChipsVariant.V2; + const isFeedChipsEnabled = feedChipsVariant !== FeedChipsVariant.None; const isMobile = useViewSizeClient(ViewSize.MobileL); const discardNewPrompt: PromptOptions = { diff --git a/packages/shared/src/components/feeds/FeedSettingsButton.tsx b/packages/shared/src/components/feeds/FeedSettingsButton.tsx index 63be7bc8bf2..b1c862103c7 100644 --- a/packages/shared/src/components/feeds/FeedSettingsButton.tsx +++ b/packages/shared/src/components/feeds/FeedSettingsButton.tsx @@ -45,7 +45,7 @@ export function FeedSettingsButton({ feature: featureFeedChips, shouldEvaluate: !isPlus, }); - const isFeedChipsEnabled = feedChipsVariant === FeedChipsVariant.V2; + const isFeedChipsEnabled = feedChipsVariant !== FeedChipsVariant.None; const { feeds, deleteFeed } = useFeeds(); const router = useRouter(); const { showPrompt } = usePrompt(); diff --git a/packages/shared/src/components/feeds/MobileFeedActions.tsx b/packages/shared/src/components/feeds/MobileFeedActions.tsx index edc7e95cd5c..67087457b9f 100644 --- a/packages/shared/src/components/feeds/MobileFeedActions.tsx +++ b/packages/shared/src/components/feeds/MobileFeedActions.tsx @@ -15,7 +15,6 @@ import { Button } from '../buttons/Button'; import { SettingsIcon } from '../icons'; import { RootPortal } from '../tooltips/Portal'; import { QuestHeaderButton } from '../header/QuestHeaderButton'; -import { GivebackGiftEntry } from '../../features/giveback/components/GivebackGiftEntry'; const ProfileSettingsMenuMobile = dynamic( () => @@ -47,7 +46,6 @@ export function MobileFeedActions(): ReactElement { /> )} - {user && ( <>
    + + ); +} diff --git a/packages/shared/src/components/layout/HeaderButtons.tsx b/packages/shared/src/components/layout/HeaderButtons.tsx index 78251d27702..054fad3445a 100644 --- a/packages/shared/src/components/layout/HeaderButtons.tsx +++ b/packages/shared/src/components/layout/HeaderButtons.tsx @@ -6,10 +6,8 @@ import ProfileButton from '../profile/ProfileButton'; import { useAuthContext } from '../../contexts/AuthContext'; import classed from '../../lib/classed'; import { useSettingsContext } from '../../contexts/SettingsContext'; -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 { @@ -23,7 +21,6 @@ export function HeaderButtons({ }: HeaderButtonsProps): ReactElement { const { isLoggedIn, isAuthReady } = useAuthContext(); const { loadedSettings } = useSettingsContext(); - const isLaptop = useViewSize(ViewSize.Laptop); if (!isAuthReady || !loadedSettings) { return ; @@ -50,7 +47,6 @@ export function HeaderButtons({ - {additionalButtons} diff --git a/packages/shared/src/components/marketing/banners/HomepageTopBanners.tsx b/packages/shared/src/components/marketing/banners/HomepageTopBanners.tsx index 0f420acb8b6..7011ffd5cef 100644 --- a/packages/shared/src/components/marketing/banners/HomepageTopBanners.tsx +++ b/packages/shared/src/components/marketing/banners/HomepageTopBanners.tsx @@ -6,12 +6,15 @@ import ReadingReminderCatLaptop from './ReadingReminderCatLaptop'; import { useReadingReminderHero } from '../../../hooks/notifications/useReadingReminderHero'; import { fileValidation, + uploadCvOpportunitySuccessContent, + uploadCvProfileSuccessContent, useUploadCv, } from '../../../features/profile/hooks/useUploadCv'; import { useActions } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import { useAuthContext } from '../../../contexts/AuthContext'; import { uploadCvBgMobile } from '../../../lib/image'; +import { useJobsFeature } from '../../../hooks/useJobsFeature'; const illustrationFrameClass = '!m-0 flex h-24 w-32 shrink-0 items-center justify-center self-center tablet:h-28 tablet:w-36'; @@ -59,7 +62,12 @@ export const HomepageTopBanners = ({ }: HomepageTopBannersProps): ReactElement | null => { const reminder = useReadingReminderHero({ requireMobile: false }); const { isLoggedIn, isAuthReady } = useAuthContext(); - const { onUpload, shouldShow: shouldShowCv } = useUploadCv(); + const { isJobsEnabled } = useJobsFeature(); + const { onUpload, shouldShow: shouldShowCv } = useUploadCv({ + modalContent: isJobsEnabled + ? uploadCvOpportunitySuccessContent + : uploadCvProfileSuccessContent, + }); const { completeAction } = useActions(); const fileInputRef = useRef(null); @@ -90,7 +98,11 @@ export const HomepageTopBanners = ({ cards.push( } onCtaClick={() => fileInputRef.current?.click()} diff --git a/packages/shared/src/components/marketing/banners/PlusMobileEntryBanner.tsx b/packages/shared/src/components/marketing/banners/PlusMobileEntryBanner.tsx index 19851ad8074..ca5af35efb4 100644 --- a/packages/shared/src/components/marketing/banners/PlusMobileEntryBanner.tsx +++ b/packages/shared/src/components/marketing/banners/PlusMobileEntryBanner.tsx @@ -14,6 +14,7 @@ import type { TargetType } from '../../../lib/log'; import { LogEvent } from '../../../lib/log'; import { useLogContext } from '../../../contexts/LogContext'; import { useBoot } from '../../../hooks'; +import { PlusSaleLabel } from '../../plus/PlusSaleLabel'; type PlusBannerProps = Omit & { targetType: TargetType; @@ -96,6 +97,7 @@ const PlusMobileEntryBanner = ({ > {ctaText} + )} {okButton !== null && ( - + )} diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx index ca77eb17474..87c82bfc681 100644 --- a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -46,6 +46,7 @@ import { requestFrameEmbeddingPermissionFromPage } from '../../features/extensio interface ReaderInstallPromptModalProps extends LazyModalCommonProps { post: Post; + targetPost?: Post; /** * Close handler for the surface that owns the Read post click (e.g. the * classic post modal). Fired alongside the prompt's own dismiss paths @@ -245,6 +246,7 @@ function BlurredArticleBackdrop(): ReactElement { function ReaderInstallPromptModal({ post, + targetPost = post, isOpen, onRequestClose, onCloseParent, @@ -306,7 +308,7 @@ function ReaderInstallPromptModal({ ? 'Install Chrome extension' : 'Install Edge extension'; const browser = isChromeBrowser ? 'chrome' : 'edge'; - const host = getPostHost(post); + const host = getPostHost(targetPost); const faviconSrc = useFaviconSrc(host); const displayUrl = getDisplayUrl(host); @@ -334,7 +336,7 @@ function ReaderInstallPromptModal({ const [embedStatus, setEmbedStatus] = useState('idle'); const hasOpenedReaderRef = useRef(false); - const isTargetEmbeddable = isEmbeddableSiteTarget(post.permalink ?? ''); + const isTargetEmbeddable = isEmbeddableSiteTarget(targetPost.permalink ?? ''); const canRequestPermissions = !!embedExtensionId && isTargetEmbeddable && hasInstalledExtension; @@ -349,9 +351,9 @@ function ReaderInstallPromptModal({ // (e.g. the classic post modal behind it). openModal({ type: LazyModal.ReaderPreview, - props: { post, onCloseParent }, + props: { post, targetPost, onCloseParent }, }); - }, [closeModal, onCloseParent, openModal, post]); + }, [closeModal, onCloseParent, openModal, post, targetPost]); // `preparing-tab` arrives once `PermissionsReady` has fired (the user has // either just granted access or had it from a previous session). Transition @@ -428,8 +430,12 @@ function ReaderInstallPromptModal({ event_name: LogEvent.ClickReaderInstallSkip, extra: JSON.stringify({ browser, post_id: post.id }), }); - if (post.permalink) { - globalThis.window?.open(post.permalink, '_blank', 'noopener,noreferrer'); + if (targetPost.permalink) { + globalThis.window?.open( + targetPost.permalink, + '_blank', + 'noopener,noreferrer', + ); } onRequestClose({} as MouseEvent); }; @@ -443,8 +449,12 @@ function ReaderInstallPromptModal({ event_name: LogEvent.ClickReaderInstallSkip, extra: JSON.stringify({ browser, post_id: post.id }), }); - if (post.permalink) { - globalThis.window?.open(post.permalink, '_blank', 'noopener,noreferrer'); + if (targetPost.permalink) { + globalThis.window?.open( + targetPost.permalink, + '_blank', + 'noopener,noreferrer', + ); } onRequestClose(event); }; @@ -480,7 +490,7 @@ function ReaderInstallPromptModal({
    + import( + /* webpackChunkName: "streakOffersModal" */ './streaks/StreakOffersModal' + ), +); + const ReputationPrivilegesModal = dynamic( () => import( @@ -544,6 +551,7 @@ export const modals = { [LazyModal.Video]: VideoModal, [LazyModal.ImageView]: ImageModal, [LazyModal.NewStreak]: NewStreakModal, + [LazyModal.StreakOffers]: StreakOffersModal, [LazyModal.ReputationPrivileges]: ReputationPrivilegesModal, [LazyModal.MarketingCta]: MarketingCtaModal, [LazyModal.Share]: ShareModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 66e87110334..cb070ec0a8e 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -43,6 +43,7 @@ export enum LazyModal { Video = 'video', ImageView = 'imageView', NewStreak = 'newStreak', + StreakOffers = 'streakOffers', RecoverStreak = 'recoverStreak', StreakFreezePurchase = 'streakFreezePurchase', ReputationPrivileges = 'reputationPrivileges', diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx index 50d99767be4..9ce975cee8f 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { HotTake } from '../../../graphql/user/userHotTake'; import { useDiscoverHotTakes } from '../../../hooks/useDiscoverHotTakes'; import { useVoteHotTake } from '../../../hooks/vote/useVoteHotTake'; @@ -48,11 +49,13 @@ const createHotTake = (id = 'take-1'): HotTake => ({ const renderComponent = (onRequestClose = jest.fn()) => { render( - , + + + , ); return { onRequestClose }; @@ -331,23 +334,26 @@ describe('HotAndColdModal', () => { it('should keep onboarding mode clipped and scrollable inside the modal shell', () => { render( - Progress header
    } - bottomSlot={
    Starter feed ready
    } - />, + + Progress header
    } + bottomSlot={
    Starter feed ready
    } + /> + , ); const modalBody = document.querySelector('section'); diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 51f9d8628af..a40cc4c58bd 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -33,6 +33,7 @@ import { Loader } from '../../Loader'; import LogoIcon from '../../../svg/LogoIcon'; import type { HotTake } from '../../../graphql/user/userHotTake'; import { getAddHotTakeProfileUrl } from '../../../features/profile/components/hotTakes/common'; +import { SnapshotButton } from '../../imageShare/SnapshotButton'; const SWIPE_THRESHOLD = 80; const ONBOARDING_INTRO_INTERESTING_OFFSET = 56; @@ -899,6 +900,7 @@ const HotTakeCard = ({ } } const isSkipVisualActive = isTop && skipEffectIntensity > 0.02; + const cardRef = useRef(null); const getSwipeDirection = (): 'right' | 'left' | null => { if (!isTop || Math.abs(swipeDelta) <= 20) { return null; @@ -960,6 +962,7 @@ const HotTakeCard = ({ return (
    )} - {hotTake.upvotes > 0 && ( -
    - - - {hotTake.upvotes} - -
    - )} +
    + {hotTake.upvotes > 0 && ( +
    + + + {hotTake.upvotes} + +
    + )} + {isTop && ( + + )} +
    {hotTake.user && ( diff --git a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx index 4306127bbca..d8bdead72b1 100644 --- a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx +++ b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx @@ -7,10 +7,14 @@ import type { Alerts } from '../../../graphql/alerts'; import { StreakMilestonePopup } from './StreakMilestonePopup'; import * as actionHook from '../../../hooks/useActions'; import * as streakHook from '../../../hooks/streaks/useReadingStreak'; +import * as conditionalFeatureHook from '../../../hooks/useConditionalFeature'; import { ActionType } from '../../../graphql/actions'; +import type { UserOffer } from '../../../graphql/offers'; +import { USER_OFFERS_QUERY } from '../../../graphql/offers'; import { LazyModal } from '../common/types'; import { MODAL_KEY } from '../../../hooks/useLazyModal'; import { DayOfWeek } from '../../../lib/date'; +import { mockGraphQL } from '../../../../__tests__/helpers/graphql'; const defaultAlerts: Alerts = { filter: true, @@ -68,6 +72,10 @@ const renderComponent = ({ beforeEach(() => { window.scrollTo = jest.fn(); + jest + .spyOn(conditionalFeatureHook, 'useConditionalFeature') + .mockReturnValue({ value: false, isLoading: false }); + jest.spyOn(actionHook, 'useActions').mockReturnValue({ completeAction: jest.fn(), checkHasCompleted, @@ -153,6 +161,69 @@ it('should not open when streaks are disabled', async () => { }); }); +describe('streak milestone offers experiment', () => { + const offers: UserOffer[] = [ + { + impressionUid: '10000000-0000-4000-8000-000000000001', + clickUrl: 'https://link.encorekit.com/one', + title: '3 Months of Music, Free', + advertiserName: 'Acme Music', + perk: '3 months free', + badgeLabel: 'free_trial', + }, + ]; + + const mockOffersResponse = (userOffers: UserOffer[]) => { + nock.cleanAll(); + mockGraphQL({ + request: { + query: USER_OFFERS_QUERY, + variables: { placement: 'STREAK_MILESTONE' }, + }, + result: { data: { userOffers } }, + }); + nock('http://localhost:3000') + .post('/graphql') + .optionally() + .times(10) + .reply(200, { data: {} }); + }; + + beforeEach(() => { + jest + .spyOn(conditionalFeatureHook, 'useConditionalFeature') + .mockReturnValue({ value: true, isLoading: false }); + }); + + it('should open the offers modal when treatment returns offers', async () => { + mockOffersResponse(offers); + + const { queryClient } = renderComponent(); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toMatchObject({ + type: LazyModal.StreakOffers, + props: { currentStreak: 5, offers }, + }); + }); + }); + + it('should fall back to the classic modal when treatment has no offers', async () => { + mockOffersResponse([]); + + const { queryClient } = renderComponent(); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toMatchObject({ + type: LazyModal.NewStreak, + props: { currentStreak: 5, maxStreak: 5 }, + }); + }); + }); +}); + it('should not open when another modal is already showing', async () => { const queryClient = new QueryClient(); queryClient.setQueryData(MODAL_KEY, { diff --git a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx index 635a4813cb8..40de3d45853 100644 --- a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx +++ b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx @@ -1,11 +1,19 @@ import type { ReactElement } from 'react'; import { useContext, useEffect, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { useActions } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import { LazyModal } from '../common/types'; import AlertContext from '../../../contexts/AlertContext'; +import { useAuthContext } from '../../../contexts/AuthContext'; import { useReadingStreak } from '../../../hooks/streaks'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { + OfferPlacement, + userOffersQueryOptions, +} from '../../../graphql/offers'; +import { featureStreakMilestoneOffers } from '../../../lib/featureManagement'; import { isNullOrUndefined } from '../../../lib/func'; /** @@ -15,57 +23,93 @@ import { isNullOrUndefined } from '../../../lib/func'; * boot popup queue. The modal opens as soon as all conditions are met * (alerts loaded, streak data loaded, user eligible). */ -export const StreakMilestonePopup = (): ReactElement => { +export const StreakMilestonePopup = (): ReactElement | null => { const { openModal, modal } = useLazyModal(); const { checkHasCompleted, isActionsFetched } = useActions(); const { alerts, loadedAlerts, updateAlerts } = useContext(AlertContext); const { streak, isStreaksEnabled } = useReadingStreak(); + const { user } = useAuthContext(); const hasOpened = useRef(false); const isDisabledMilestone = checkHasCompleted( ActionType.DisableReadingStreakMilestone, ); + const shouldShow = ![ + !loadedAlerts, + !isStreaksEnabled, + !isActionsFetched, + isNullOrUndefined(isDisabledMilestone), + isDisabledMilestone, + alerts?.showStreakMilestone !== true, + !streak?.current, + !!modal, + ].some(Boolean); + + // Enrollment only happens when the popup would actually show, so users who + // never hit a milestone don't dilute the experiment split. + const { value: offersEnabled, isLoading: isOffersFeatureLoading } = + useConditionalFeature({ + feature: featureStreakMilestoneOffers, + shouldEvaluate: shouldShow, + }); + + const { data: offers, isPending: areOffersPending } = useQuery({ + ...userOffersQueryOptions({ + user, + placement: OfferPlacement.StreakMilestone, + }), + enabled: shouldShow && !isOffersFeatureLoading && offersEnabled, + }); + useEffect(() => { - if (hasOpened.current) { + if (hasOpened.current || !shouldShow || isOffersFeatureLoading) { return; } - const shouldHide = [ - !loadedAlerts, - !isStreaksEnabled, - !isActionsFetched, - isNullOrUndefined(isDisabledMilestone), - isDisabledMilestone, - alerts?.showStreakMilestone !== true, - !streak?.current, - !!modal, - ].some(Boolean); + // Treatment waits for the offers fetch to settle; an error resolves it + // (offers stays undefined) and falls back to the classic popup. + if (offersEnabled && areOffersPending) { + return; + } - if (shouldHide) { + if (!streak?.current) { return; } hasOpened.current = true; + const onAfterClose = () => { + updateAlerts?.({ showStreakMilestone: false }); + }; + + if (offersEnabled && offers?.length) { + openModal({ + type: LazyModal.StreakOffers, + props: { + currentStreak: streak.current, + offers, + onAfterClose, + }, + }); + return; + } + openModal({ type: LazyModal.NewStreak, props: { - currentStreak: streak?.current, - maxStreak: streak?.max, - onAfterClose: () => { - updateAlerts({ showStreakMilestone: false }); - }, + currentStreak: streak.current, + maxStreak: streak.max, + onAfterClose, }, }); }, [ - alerts?.showStreakMilestone, - isActionsFetched, - isDisabledMilestone, - isStreaksEnabled, - loadedAlerts, - modal, + areOffersPending, + isOffersFeatureLoading, + offers, + offersEnabled, openModal, + shouldShow, streak, updateAlerts, ]); diff --git a/packages/shared/src/components/modals/streaks/StreakOffersModal.spec.tsx b/packages/shared/src/components/modals/streaks/StreakOffersModal.spec.tsx new file mode 100644 index 00000000000..65f04bcc029 --- /dev/null +++ b/packages/shared/src/components/modals/streaks/StreakOffersModal.spec.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { UserOffer } from '../../../graphql/offers'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { useViewSize } from '../../../hooks/useViewSize'; +import StreakOffersModal from './StreakOffersModal'; + +const mockConfirmDelivered = jest.fn(); + +jest.mock('../../../graphql/offers', () => ({ + ...jest.requireActual('../../../graphql/offers'), + confirmOffersDelivered: (...args: unknown[]) => mockConfirmDelivered(...args), +})); + +jest.mock('../../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../../hooks/useViewSize'), + useViewSize: jest.fn(), +})); + +// jsdom has no PointerEvent; MouseEvent carries the clientX the swipe needs +if (typeof window.PointerEvent === 'undefined') { + window.PointerEvent = MouseEvent as unknown as typeof PointerEvent; +} + +const mockUseViewSize = useViewSize as jest.Mock; +const logEvent = jest.fn(); +const onRequestClose = jest.fn(); + +const offers: UserOffer[] = [ + { + impressionUid: '10000000-0000-4000-8000-000000000001', + clickUrl: 'https://link.encorekit.com/one', + title: '3 Months of Music, Free', + advertiserName: 'Acme Music', + advertiserLogo: 'https://cdn.example.com/music.png', + perk: '3 months free', + badgeLabel: 'free_trial', + }, + { + impressionUid: '10000000-0000-4000-8000-000000000002', + clickUrl: 'https://link.encorekit.com/two', + title: 'Get 50% off Notes Pro', + advertiserName: 'Acme Notes', + advertiserLogo: 'https://cdn.example.com/notes.png', + perk: '50% off', + badgeLabel: 'discount', + }, +]; + +const renderComponent = ({ + currentStreak = 7, +}: { currentStreak?: number } = {}) => { + const client = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + + return render( + + + , + ); +}; + +describe('StreakOffersModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockConfirmDelivered.mockResolvedValue({ _: true }); + mockUseViewSize.mockReturnValue(false); + document.body.innerHTML = '
    '; + }); + + it('renders all offers on desktop and confirms delivery once', async () => { + renderComponent(); + + expect(screen.getByText('3 Months of Music, Free')).toBeInTheDocument(); + expect(screen.getByText('Get 50% off Notes Pro')).toBeInTheDocument(); + // 7-day streak resolves to the Flame tier from the design ladder + expect(screen.getByText('day streak')).toBeInTheDocument(); + expect(screen.getByText('Flame')).toBeInTheDocument(); + expect(screen.getByText('A full week, unbroken')).toBeInTheDocument(); + + await waitFor(() => + expect(mockConfirmDelivered).toHaveBeenCalledWith( + offers.map((offer) => offer.impressionUid), + ), + ); + expect(mockConfirmDelivered).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.Impression, + target_type: TargetType.StreakOffer, + target_id: offers[0].impressionUid, + }), + ); + }); + + it('opens the tokenized click url and marks the offer claimed', async () => { + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => window); + + renderComponent(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Claim' })[0]); + + expect(openSpy).toHaveBeenCalledWith( + offers[0].clickUrl, + '_blank', + 'noopener,noreferrer', + ); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.Click, + target_type: TargetType.StreakOffer, + target_id: offers[0].impressionUid, + }), + ); + await waitFor(() => + expect(screen.getByText('Claimed')).toBeInTheDocument(), + ); + }); + + it('derives copy from the streak for days off the design ladder', () => { + // the milestone alert fires on Fibonacci days (2, 8, ...) that the + // design ladder doesn't contain — copy must never contradict the count + renderComponent({ currentStreak: 8 }); + + expect(screen.getByText('Flame')).toBeInTheDocument(); + expect(screen.getByText('8 days in a row')).toBeInTheDocument(); + expect(screen.queryByText('A full week, unbroken')).not.toBeInTheDocument(); + }); + + it('falls back to the first tier below the ladder start', () => { + renderComponent({ currentStreak: 2 }); + + expect(screen.getByText('Spark')).toBeInTheDocument(); + expect(screen.getByText('2 days in a row')).toBeInTheDocument(); + }); + + it('logs a dismissal when closed via the X on desktop', () => { + renderComponent(); + + fireEvent.click(screen.getByTitle('Close')); + + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.DismissStreakOffers, + target_type: TargetType.StreakOffer, + extra: JSON.stringify({ method: 'close', claimed: 0 }), + }), + ); + expect(onRequestClose).toHaveBeenCalled(); + }); + + it('keeps the swiped card when the drag ends with a click on a card', async () => { + mockUseViewSize.mockReturnValue(true); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => window); + + renderComponent(); + + const firstCard = screen + .getByText(offers[0].advertiserName) + .closest('button'); + + if (!firstCard) { + throw new Error('carousel card not found'); + } + + // swipe left past the threshold; browsers then fire a click on the card + fireEvent.pointerDown(firstCard, { clientX: 200 }); + fireEvent.pointerMove(firstCard, { clientX: 80 }); + fireEvent.pointerUp(firstCard); + fireEvent.click(firstCard); + + fireEvent.click(screen.getByRole('button', { name: 'Claim gift' })); + + expect(openSpy).toHaveBeenCalledWith( + offers[1].clickUrl, + '_blank', + 'noopener,noreferrer', + ); + await waitFor(() => + expect(mockConfirmDelivered).toHaveBeenCalledWith([ + offers[1].impressionUid, + ]), + ); + }); + + it('confirms only the visible card on mobile and dismisses via no thanks', async () => { + mockUseViewSize.mockReturnValue(true); + + renderComponent(); + + await waitFor(() => + expect(mockConfirmDelivered).toHaveBeenCalledWith([ + offers[0].impressionUid, + ]), + ); + expect(mockConfirmDelivered).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: 'No thanks' })); + + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.DismissStreakOffers, + target_type: TargetType.StreakOffer, + extra: JSON.stringify({ method: 'decline', claimed: 0 }), + }), + ); + expect(onRequestClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/modals/streaks/StreakOffersModal.tsx b/packages/shared/src/components/modals/streaks/StreakOffersModal.tsx new file mode 100644 index 00000000000..32267b527dc --- /dev/null +++ b/packages/shared/src/components/modals/streaks/StreakOffersModal.tsx @@ -0,0 +1,175 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { UserOffer } from '../../../graphql/offers'; +import { confirmOffersDelivered } from '../../../graphql/offers'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { generateQueryKey, RequestKey } from '../../../lib/query'; +import { useViewSize, ViewSize } from '../../../hooks/useViewSize'; +import useLogEventOnce from '../../../hooks/log/useLogEventOnce'; +import { StreakOfferCarousel } from '../../streak/offers/StreakOfferCarousel'; +import { StreakOfferSplit } from '../../streak/offers/StreakOfferSplit'; +import { Modal } from '../common/Modal'; +import { ModalClose } from '../common/ModalClose'; +import type { LazyModalCommonProps, ModalProps } from '../common/Modal'; + +export type StreakOffersModalProps = LazyModalCommonProps & + Pick & { + currentStreak: number; + offers: UserOffer[]; + }; + +export default function StreakOffersModal({ + currentStreak, + offers, + onRequestClose, + ...props +}: StreakOffersModalProps): ReactElement { + const isMobile = useViewSize(ViewSize.MobileL); + const queryClient = useQueryClient(); + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const [claimedUids, setClaimedUids] = useState>(new Set()); + const [deliveredUids] = useState>(new Set()); + const { mutate: confirmDelivered } = useMutation({ + mutationFn: confirmOffersDelivered, + }); + + useLogEventOnce(() => ({ + event_name: LogEvent.Impression, + target_type: TargetType.StreaksMilestone, + target_id: currentStreak?.toString(), + })); + + const invalidatedStreak = useRef(false); + + useEffect(() => { + if (invalidatedStreak.current) { + return; + } + + invalidatedStreak.current = true; + // the streaks query is cached with a staleTime; the popup moment is when + // it must refresh (mirrors NewStreakModal) + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.UserStreak, user), + }); + }, [queryClient, user]); + + // Render-then-confirm: each offer is confirmed once, at the moment it + // becomes visible. Encore does not dedupe, so the set guards replays. + const onVisible = useCallback( + (visibleOffers: UserOffer[]) => { + const fresh = visibleOffers.filter( + (offer) => !deliveredUids.has(offer.impressionUid), + ); + + if (!fresh.length) { + return; + } + + fresh.forEach((offer) => deliveredUids.add(offer.impressionUid)); + confirmDelivered(fresh.map((offer) => offer.impressionUid)); + fresh.forEach((offer) => + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.StreakOffer, + target_id: offer.impressionUid, + extra: JSON.stringify({ + brand: offer.advertiserName, + streak: currentStreak, + }), + }), + ); + }, + [confirmDelivered, currentStreak, deliveredUids, logEvent], + ); + + const onClaim = useCallback( + (offer: UserOffer) => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.StreakOffer, + target_id: offer.impressionUid, + extra: JSON.stringify({ + brand: offer.advertiserName, + streak: currentStreak, + }), + }); + window.open(offer.clickUrl, '_blank', 'noopener,noreferrer'); + setClaimedUids((current) => new Set(current).add(offer.impressionUid)); + }, + [currentStreak, logEvent], + ); + + // Every dismissal path (X, backdrop, escape, "No thanks") funnels through + // here so the metric is comparable across variants and platforms; the + // method and claim count distinguish explicit declines in analysis. + const dismissLogged = useRef(false); + const logDismiss = useCallback( + (method: 'close' | 'decline') => { + if (dismissLogged.current) { + return; + } + + dismissLogged.current = true; + logEvent({ + event_name: LogEvent.DismissStreakOffers, + target_type: TargetType.StreakOffer, + target_id: currentStreak?.toString(), + extra: JSON.stringify({ method, claimed: claimedUids.size }), + }); + }, + [claimedUids.size, currentStreak, logEvent], + ); + + const onClose = useCallback( + (event?: React.MouseEvent | React.KeyboardEvent) => { + logDismiss('close'); + onRequestClose(event); + }, + [logDismiss, onRequestClose], + ); + + const onDecline = useCallback(() => { + logDismiss('decline'); + onRequestClose(); + }, [logDismiss, onRequestClose]); + + return ( + + {/* The celebration gradient must clip to the same radius as the modal + container (tablet:rounded-16) and the mobile drawer (rounded-t-16), + otherwise its square corners paint outside the rounded frame. */} + + + {isMobile ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/shared/src/components/modals/user/CookieConsentModal.spec.tsx b/packages/shared/src/components/modals/user/CookieConsentModal.spec.tsx index 0043e0c5afa..30a0c64c8bd 100644 --- a/packages/shared/src/components/modals/user/CookieConsentModal.spec.tsx +++ b/packages/shared/src/components/modals/user/CookieConsentModal.spec.tsx @@ -4,10 +4,8 @@ import { QueryClient } from '@tanstack/react-query'; import ReactModal from 'react-modal'; import { CookieConsentModal } from './CookieConsentModal'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; -import { - GdprConsentKey, - useCookieBanner, -} from '../../../hooks/useCookieBanner'; +import { GdprConsentKey } from '../../../hooks/useCookieBanner'; +import { useConsentCookie } from '../../../hooks/useCookieConsent'; import { expireCookie, getCookies } from '../../../lib/cookie'; let client: QueryClient; @@ -26,13 +24,13 @@ beforeEach(() => { }); const Wrapper = () => { - const { onAcceptCookies } = useCookieBanner(); + const { saveCookies } = useConsentCookie(GdprConsentKey.Necessary); return ( ); }; diff --git a/packages/shared/src/components/notifications/EmailNotificationsTab.tsx b/packages/shared/src/components/notifications/EmailNotificationsTab.tsx index e71392501d8..bfa8d39dbc5 100644 --- a/packages/shared/src/components/notifications/EmailNotificationsTab.tsx +++ b/packages/shared/src/components/notifications/EmailNotificationsTab.tsx @@ -17,6 +17,7 @@ import PersonalizedDigest from './PersonalizedDigest'; import NotificationCheckbox from './NotificationCheckbox'; import NotificationSwitch from './NotificationSwitch'; import NotificationGroupToggle from './NotificationToggle'; +import { useJobsFeature } from '../../hooks/useJobsFeature'; const EmailNotificationsTab = (): ReactElement => { const { @@ -27,6 +28,9 @@ const EmailNotificationsTab = (): ReactElement => { unsubscribeAllEmail, emailsDisabled, } = useNotificationSettings(); + const { isJobsEnabled } = useJobsFeature(); + const showOpportunitiesToggle = + isJobsEnabled || getGroupStatus('opportunities', 'email'); return (
    @@ -121,6 +125,15 @@ const EmailNotificationsTab = (): ReactElement => { ) } /> + + toggleGroup('world', !getGroupStatus('world', 'email'), 'email') + } + /> { ) } /> - - Get notified only when there's a role that fits your skills - and preferences. No spam, no pressure. - - } - checked={getGroupStatus('opportunities', 'email')} - onToggle={() => - toggleGroup( - 'opportunities', - !getGroupStatus('opportunities', 'email'), - 'email', - ) - } - /> + {showOpportunitiesToggle && ( + + Get notified only when there's a role that fits your + skills and preferences. No spam, no pressure. + + } + checked={getGroupStatus('opportunities', 'email')} + onToggle={() => + toggleGroup( + 'opportunities', + !getGroupStatus('opportunities', 'email'), + 'email', + ) + } + /> + )} diff --git a/packages/shared/src/components/notifications/FirstNotificationLegacy.tsx b/packages/shared/src/components/notifications/FirstNotificationLegacy.tsx deleted file mode 100644 index 929229f2c9f..00000000000 --- a/packages/shared/src/components/notifications/FirstNotificationLegacy.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useEffect } from 'react'; -import usePersistentContext from '../../hooks/usePersistentContext'; -import { firstNotificationLink } from '../../lib/constants'; -import NotificationItemLegacy from './NotificationItemLegacy'; -import { NotificationType, NotificationIconType } from './utils'; - -const READ_KEY = 'FIRST_NOTIFICATION_READ'; - -function FirstNotificationLegacy(): ReactElement { - const [isUnread, setIsUnread] = usePersistentContext(READ_KEY, true); - - useEffect(() => { - return () => { - setIsUnread(false); - }; - // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - ); -} - -export default FirstNotificationLegacy; diff --git a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx index 3a1df5eea21..e121b549e5c 100644 --- a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx @@ -40,6 +40,7 @@ import NotificationCheckbox from './NotificationCheckbox'; import NotificationSwitch from './NotificationSwitch'; import NotificationGroupToggle from './NotificationToggle'; import ReadingReminderToggle from './ReadingReminderToggle'; +import { useJobsFeature } from '../../hooks/useJobsFeature'; const InAppNotificationsTab = (): ReactElement => { const { logEvent } = useLogContext(); @@ -53,6 +54,9 @@ const InAppNotificationsTab = (): ReactElement => { toggleGroup, getGroupStatus, } = useNotificationSettings(); + const { isJobsEnabled } = useJobsFeature(); + const showOpportunitiesToggle = + isJobsEnabled || getGroupStatus('opportunities', 'inApp'); const onTogglePush = async () => { logEvent({ @@ -233,23 +237,34 @@ const InAppNotificationsTab = (): ReactElement => { } /> - Get notified only when there's a role that fits your skills - and preferences. No spam, no pressure. - - } - checked={getGroupStatus('opportunities', 'inApp')} + id="world" + label="Your world" + description="Get notified when a district in your world reaches a new level." + checked={getGroupStatus('world', 'inApp')} onToggle={() => - toggleGroup( - 'opportunities', - !getGroupStatus('opportunities', 'inApp'), - 'inApp', - ) + toggleGroup('world', !getGroupStatus('world', 'inApp'), 'inApp') } /> + {showOpportunitiesToggle && ( + + Get notified only when there's a role that fits your + skills and preferences. No spam, no pressure. + + } + checked={getGroupStatus('opportunities', 'inApp')} + onToggle={() => + toggleGroup( + 'opportunities', + !getGroupStatus('opportunities', 'inApp'), + 'inApp', + ) + } + /> + )} diff --git a/packages/shared/src/components/notifications/NotificationItem.spec.tsx b/packages/shared/src/components/notifications/NotificationItem.spec.tsx index e0ec4607ebb..92383ac3425 100644 --- a/packages/shared/src/components/notifications/NotificationItem.spec.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.spec.tsx @@ -6,7 +6,6 @@ import type { NextRouter } from 'next/router'; import { useRouter } from 'next/router'; import type { NotificationItemProps } from './NotificationItem'; import NotificationItem from './NotificationItem'; -import NotificationItemLegacy from './NotificationItemLegacy'; import { NotificationAttachmentType, NotificationAvatarType, @@ -259,11 +258,6 @@ describe('UserReceivedAward say thanks action', () => { await screen.findByText('Say thanks'); }); - it('should render the "Say thanks" action in the legacy notification item', async () => { - renderComponent(); - await screen.findByRole('button', { name: 'Say thanks' }); - }); - it('should not render the action on other notification types', async () => { renderComponent(); await screen.findByText(sampleNotificationTitle); diff --git a/packages/shared/src/components/notifications/NotificationItemAvatar.tsx b/packages/shared/src/components/notifications/NotificationItemAvatar.tsx index 2b0f9c98be3..4b75f721aa4 100644 --- a/packages/shared/src/components/notifications/NotificationItemAvatar.tsx +++ b/packages/shared/src/components/notifications/NotificationItemAvatar.tsx @@ -6,7 +6,12 @@ import SourceButton from '../cards/common/SourceButton'; import { ProfileTooltip } from '../profile/ProfileTooltip'; import { ProfileImageLink } from '../profile/ProfileImageLink'; import { ProfileImageSize } from '../ProfilePicture'; -import { BriefGradientIcon, BriefIcon, MedalBadgeIcon } from '../icons'; +import { + BriefGradientIcon, + BriefIcon, + MedalBadgeIcon, + WorldIcon, +} from '../icons'; import { IconSize } from '../Icon'; import { BadgeIconGoldGradient } from '../badges/BadgeIcon'; import { Image, ImageType } from '../image/Image'; @@ -99,6 +104,14 @@ function NotificationItemAvatar({ ); } + if (type === NotificationAvatarType.World) { + return ( + + + + ); + } + if (type === NotificationAvatarType.Achievement) { return ( { - isUnread?: boolean; - targetUrl: string; - createdAt?: Date; - onClick?: ( - e: - | React.MouseEvent - | React.KeyboardEvent, - ) => void; -} - -const NotificationOptionsButton = ({ - notification, -}: { - notification: Pick; -}): ReactElement => { - const { - preferences, - isFetching, - clearNotificationPreference, - muteNotification, - } = useNotificationPreference({ - params: notification - ? [ - { - notificationType: notification.type, - referenceId: notification.referenceId, - }, - ] - : [], - }); - - const onItemClick = () => { - const isMuted = - preferences?.[0]?.status === NotificationPreferenceStatus.Muted; - const preferenceCommand = isMuted - ? clearNotificationPreference - : muteNotification; - - return preferenceCommand({ - type: notification.type, - referenceId: notification.referenceId, - }); - }; - - const Icon = (): ReactElement | null => { - if (!notification) { - return null; - } - - if (isFetching) { - return ; - } - - const NotifIcon = - preferences[0]?.status === NotificationPreferenceStatus.Muted - ? BellIcon - : BellDisabledIcon; - - return ; - }; - - const label = useMemo((): string => { - if (!notification) { - return ''; - } - - if (isFetching) { - return 'Fetching your preference'; - } - - const isMuted = - preferences[0]?.status === NotificationPreferenceStatus.Muted; - const copy = notificationMutingCopy[notification?.type]; - - if (!copy) { - return ''; - } - - return isMuted ? copy.unmute : copy.mute; - }, [notification, preferences, isFetching]); - const options = [{ icon: , label, action: onItemClick }]; - return ( - - - -
    - - ); -}; diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index 27bf7aa79c4..1eb3342d594 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useContext } from 'react'; import dynamic from 'next/dynamic'; import { PageWidgets } from '../utilities'; @@ -43,14 +43,63 @@ const SquadEntityCard = dynamic( }, ); +/** + * The points in the rail an ad template may follow with a slot: one per real + * widget, in render order. PostSidebarAdWidget and MentionedToolsWidget are + * absent on purpose — both are already commercial units, so following them + * would stack two ads. + */ +export enum PostWidgetPosition { + Source = 'source', + Creator = 'creator', + Share = 'share', + Highlights = 'highlights', + SimilarPosts = 'similarPosts', +} + export type PostWidgetsProps = Omit & - Omit; + Omit & { + /** Ad templates optimise for impressions, not accounts. */ + hideSignupWidget?: boolean; + /** Ad templates give the table of contents' space to a slot instead. */ + hideToc?: boolean; + /** Renders a slot after the widget at each position. */ + getRailAd?: (position: PostWidgetPosition) => ReactNode; + /** Rendered last, below the footer links. */ + trailing?: ReactNode; + }; + +/** + * Half the rail's widgets decide internally whether they have anything to show, + * so an ad placed after one can end up following nothing and landing against + * the previous ad. `display: contents` keeps the pair in the rail's own flex + * flow, and the slot hides itself whenever it comes out first — which only + * happens when its widget rendered nothing. + */ +function WidgetWithAd({ + widget, + ad, +}: { + widget: ReactNode; + ad: ReactNode; +}): ReactElement { + return ( +
    + {widget} +
    {ad}
    +
    + ); +} export function PostWidgets({ onCopyPostLink, post, className, origin, + hideSignupWidget = false, + hideToc = false, + getRailAd, + trailing, }: PostWidgetsProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); const { source } = post; @@ -81,34 +130,62 @@ export function PostWidgets({ ); } + const withAd = ( + position: PostWidgetPosition, + widget: ReactNode, + ): ReactNode => { + const ad = getRailAd?.(position); + + if (!ad) { + return widget; + } + + return ; + }; + return ( - - {sourceCard} - {creator && ( - + {!hideSignupWidget && } + {withAd(PostWidgetPosition.Source, sourceCard)} + {withAd( + PostWidgetPosition.Creator, + creator && ( + + ), )} - - - - {tokenRefreshed && } + {withAd( + PostWidgetPosition.Share, + <> + + + , + )} + {withAd(PostWidgetPosition.Highlights, )} + {tokenRefreshed && ( + + )} + {trailing} ); } diff --git a/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.spec.tsx b/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.spec.tsx new file mode 100644 index 00000000000..8a9c8dd6919 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.spec.tsx @@ -0,0 +1,625 @@ +import React from 'react'; +import { + act, + fireEvent, + render as rtlRender, + screen, +} from '@testing-library/react'; +import { ArbitrageAdFormat, ArbitrageAdSlot } from './ArbitrageAdSlot'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import type { AuthContextData } from '../../../contexts/AuthContext'; +import AuthContext from '../../../contexts/AuthContext'; +import { getLogContextStatic } from '../../../contexts/LogContext'; +import type { LogContextData } from '../../../hooks/log/useLogContextData'; +import { LogEvent } from '../../../lib/log'; +import { AdActions } from '../../../lib/ads'; +import type { AdsenseSlots } from '../../../features/monetization/adsense'; +import { ADSENSE_CLIENT_ID } from '../../../features/monetization/adsense'; +import { + featurePostAdsense, + featureReadAdsense, +} from '../../../lib/featureManagement'; +import { useFeature } from '../../GrowthBookProvider'; +import { ORGANIC_SLOT } from './slots'; + +jest.mock('../../../hooks/useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +jest.mock('../../GrowthBookProvider', () => ({ + ...(jest.requireActual('../../GrowthBookProvider') as Record< + string, + unknown + >), + useFeature: jest.fn(), +})); + +jest.mock('../../../lib/constants', () => ({ + ...(jest.requireActual('../../../lib/constants') as Record), + isDevelopment: false, +})); + +// The slot maps ship hardcoded; tests swap in fixtures via these mutable +// module objects rather than asserting against production unit ids. +jest.mock('./slots', () => ({ + ...(jest.requireActual('./slots') as Record), + READ_ADSENSE_SLOTS: {}, + ORGANIC_ADSENSE_SLOTS: {}, +})); + +const mockConstants = jest.requireMock('../../../lib/constants') as { + isDevelopment: boolean; +}; +const mockSlotMaps = jest.requireMock('./slots') as { + READ_ADSENSE_SLOTS: AdsenseSlots; + ORGANIC_ADSENSE_SLOTS: AdsenseSlots; +}; + +const mockUseConditionalFeature = jest.mocked(useConditionalFeature); +const mockUseFeature = jest.mocked(useFeature); + +const flags = { organic: false, read: true }; + +// Both surfaces are anonymous-only and wait for boot, so the default render +// is an anonymous visitor with auth resolved. +const anonymousAuth = { isAuthReady: true } as unknown as AuthContextData; +const render = ( + ui: React.ReactElement, + options?: Parameters[1], +): ReturnType => + rtlRender( + {ui}, + options, + ); + +// Nested inside the anonymous default; the closest provider wins. +const renderLoggedIn = (ui: React.ReactElement) => + render( + + {ui} + , + ); + +/** Fills the /read map — the surface has no flag, the map alone decides. */ +const setSlots = (slots: AdsenseSlots): void => { + mockSlotMaps.READ_ADSENSE_SLOTS = slots; +}; + +const setOrganicSlots = (slots: AdsenseSlots): void => { + flags.organic = Object.keys(slots).length > 0; + mockSlotMaps.ORGANIC_ADSENSE_SLOTS = slots; +}; + +beforeEach(() => { + mockConstants.isDevelopment = false; + flags.organic = false; + flags.read = true; + mockSlotMaps.READ_ADSENSE_SLOTS = {}; + mockSlotMaps.ORGANIC_ADSENSE_SLOTS = {}; + mockUseFeature.mockImplementation((feature) => + feature === featureReadAdsense ? flags.read : feature.defaultValue, + ); + mockUseConditionalFeature.mockImplementation( + ({ feature, shouldEvaluate }) => { + if (feature === featurePostAdsense && shouldEvaluate) { + return { value: flags.organic, isLoading: false }; + } + return { value: feature.defaultValue, isLoading: false }; + }, + ); +}); + +describe('ArbitrageAdSlot', () => { + it('renders nothing while the slot map is empty', () => { + setSlots({}); + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the density-review placeholder only in development', () => { + mockConstants.isDevelopment = true; + setSlots({}); + render(); + + expect(screen.getByTestId('arbitrage-ad-slot-3')).toBeInTheDocument(); + expect(screen.queryByTestId('adsense-slot-3')).not.toBeInTheDocument(); + }); + + it('restricts a responsive unit to its format shape', () => { + setSlots({ '3': { id: '1234567890', type: 'display' } }); + render( + , + ); + + // Left on `auto`, a 300px-wide slot is free to answer with a 300x600. + const ins = screen.getByTestId('adsense-slot-3'); + expect(ins).toHaveAttribute('data-ad-format', 'rectangle'); + expect(ins).not.toHaveAttribute('data-full-width-responsive'); + }); + + it('keeps banners horizontal so a rectangle cannot fill them', () => { + setSlots({ '2': { id: '1234567890', type: 'display' } }); + render( + , + ); + + expect(screen.getByTestId('adsense-slot-2')).toHaveAttribute( + 'data-ad-format', + 'horizontal', + ); + }); + + it('drops phone-hidden slots below the tablet breakpoint', () => { + setSlots({ '5': { id: '1234567890', type: 'inArticle' } }); + render( + , + ); + + expect(screen.getByTestId('adsense-slot-5').parentElement).toHaveClass( + 'hidden', + 'tablet:block', + ); + }); + + it('renders a live in-article unit when the slot is configured', () => { + setSlots({ '3': { id: '1234567890', type: 'inArticle' } }); + render( + , + ); + + const ins = screen.getByTestId('adsense-slot-3'); + expect(ins).toHaveClass('adsbygoogle'); + expect(ins).toHaveAttribute('data-ad-client', ADSENSE_CLIENT_ID); + expect(ins).toHaveAttribute('data-ad-slot', '1234567890'); + expect(ins).toHaveAttribute('data-ad-layout', 'in-article'); + expect(ins).toHaveAttribute('data-ad-format', 'fluid'); + expect(screen.queryByTestId('arbitrage-ad-slot-3')).not.toBeInTheDocument(); + }); + + it('never hides a slot AdSense has not declined', async () => { + // Height and a missing iframe cannot tell a slow auction from a declined + // ad, and hiding is unrecoverable: display:none is not something Google + // renders into, so a slot hidden while waiting never fills at all. Only + // data-ad-status="unfilled" means no ad, and the wrapper's CSS rule + // handles that one. + jest.useFakeTimers(); + setSlots({ '2': { id: '2222222222', type: 'display' } }); + render( + , + ); + + await act(async () => { + jest.advanceTimersByTime(60_000); + }); + + expect(screen.getByTestId('adsense-slot-2').parentElement).not.toHaveClass( + '!hidden', + ); + jest.useRealTimers(); + }); + + it('mounts no before the slot becomes eligible', () => { + // adsbygoogle.push({}) binds to the first uninitialised ins in document + // order, not to the slot that pushed — so an ins that is not meant to be + // requested yet must not exist at all. The suite-wide IntersectionObserver + // mock never fires, which models exactly that state. + setSlots({ '3': { id: '1234567890', type: 'display' } }); + render(); + + expect(screen.queryByTestId('adsense-slot-3')).not.toBeInTheDocument(); + }); + + it('requests eager slots on mount without waiting for intersection', () => { + // The suite-wide IntersectionObserver mock never fires callbacks, so a + // push proves the eager path skipped the observer entirely. + window.adsbygoogle = []; + setSlots({ '2': { id: '2222222222', type: 'display' } }); + render( + , + ); + + expect(window.adsbygoogle).toHaveLength(1); + }); + + it('serves test creatives on any host but production', () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + render( + , + ); + + expect(screen.getByTestId('adsense-slot-2')).toHaveAttribute( + 'data-adtest', + 'on', + ); + }); + + it('renders fixed-size units at exactly the configured size', () => { + setOrganicSlots({ + [ORGANIC_SLOT.railHalfPage]: { + id: '3333333333', + type: 'display', + width: 300, + height: 600, + }, + }); + render( + , + ); + + const ins = screen.getByTestId(`adsense-slot-${ORGANIC_SLOT.railHalfPage}`); + expect(ins).toHaveStyle({ width: '300px', height: '600px' }); + expect(ins).not.toHaveAttribute('data-ad-format'); + }); + + it('never renders for logged-in users', () => { + setSlots({ '3': { id: '1234567890', type: 'inArticle' } }); + const { container } = renderLoggedIn( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('stays dark until auth resolves, so a logged-in boot never sees a flash', () => { + setSlots({ '3': { id: '1234567890', type: 'inArticle' } }); + const { container } = rtlRender( + + + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('goes dark when the read_adsense kill switch is off', () => { + flags.read = false; + setSlots({ '3': { id: '1234567890', type: 'inArticle' } }); + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('collapses unconfigured and empty-id slots in live mode', () => { + setSlots({ + '3': { id: '1234567890', type: 'inArticle' }, + '12': { id: '', type: 'display' }, + }); + const { container } = render( + <> + + + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('ArbitrageAdSlot on the organic surface', () => { + const organicFixture: AdsenseSlots = { + [ORGANIC_SLOT.topLeaderboard]: { id: '5555555555', type: 'display' }, + }; + + it('renders when post_adsense is on for a non-Plus user', () => { + setOrganicSlots(organicFixture); + render( + , + ); + + expect( + screen.getByTestId(`adsense-slot-${ORGANIC_SLOT.topLeaderboard}`), + ).toHaveAttribute('data-ad-slot', '5555555555'); + }); + + it('never renders for logged-in users', () => { + setOrganicSlots(organicFixture); + const { container } = renderLoggedIn( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('ignores the read flag and map entirely', () => { + setSlots({ + [ORGANIC_SLOT.topLeaderboard]: { id: '9999999999', type: 'display' }, + }); + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('never enrolls into post_adsense while no unit has an id', () => { + // Enrollment with nothing renderable fills the experiment with users for + // whom variant and control are byte-identical. + mockSlotMaps.ORGANIC_ADSENSE_SLOTS = { + [ORGANIC_SLOT.topLeaderboard]: { id: '', type: 'display' }, + }; + flags.organic = true; + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + expect(mockUseConditionalFeature).toHaveBeenCalledWith( + expect.objectContaining({ shouldEvaluate: false }), + ); + }); + + it('shows no development placeholder outside the read template', () => { + mockConstants.isDevelopment = true; + setOrganicSlots({}); + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('ProgrammaticAd telemetry', () => { + const LogContext = getLogContextStatic(); + const logEvent = jest.fn(); + + const renderWithLog = (ui: React.ReactElement) => + rtlRender( + + + {ui} + + , + ); + + const loggedEvents = (): string[] => + logEvent.mock.calls.map(([event]) => event.event_name); + + beforeEach(() => { + logEvent.mockClear(); + }); + + it('logs the request exactly once with the standardized extras', () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + const { rerender } = renderWithLog( + , + ); + rerender( + , + ); + + const requests = logEvent.mock.calls.filter( + ([event]) => event.event_name === LogEvent.RequestAdsenseSlot, + ); + expect(requests).toHaveLength(1); + expect(JSON.parse(requests[0][0].extra)).toMatchObject({ + slot: 2, + unit: '2222222222', + unit_type: 'display', + format: 'leaderboard', + surface: 'read', + }); + }); + + it('logs an empty slot when AdSense answers unfilled', async () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + renderWithLog( + , + ); + + screen + .getByTestId('adsense-slot-2') + .setAttribute('data-ad-status', 'unfilled'); + await act(async () => { + await Promise.resolve(); + }); + + expect(loggedEvents()).toContain(LogEvent.EmptyAdsenseSlot); + expect(loggedEvents()).not.toContain(LogEvent.FillAdsenseSlot); + }); + + it('logs a fill when the creative iframe lands', async () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + renderWithLog( + , + ); + + screen + .getByTestId('adsense-slot-2') + .appendChild(document.createElement('iframe')); + await act(async () => { + await Promise.resolve(); + }); + + expect(loggedEvents()).toContain(LogEvent.FillAdsenseSlot); + }); + + it('logs a click once when focus moves into the filled creative', async () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + renderWithLog( + , + ); + + const iframe = document.createElement('iframe'); + screen.getByTestId('adsense-slot-2').appendChild(iframe); + await act(async () => { + await Promise.resolve(); + }); + + // A click on a cross-origin creative never bubbles here; the observable + // is focus landing on the iframe as the window blurs. + iframe.focus(); + fireEvent.blur(window); + fireEvent.blur(window); + + const clicks = logEvent.mock.calls.filter( + ([event]) => + event.event_name === AdActions.Click && event.target_type === 'ad', + ); + expect(clicks).toHaveLength(1); + expect(clicks[0][0]).toMatchObject({ + target_id: '2222222222', + ad_provider_id: 'adsense', + }); + expect(JSON.parse(clicks[0][0].extra)).toMatchObject({ + slot: 2, + unit: '2222222222', + surface: 'read', + signal: 'focus-blur', + }); + }); + + it('logs the loose impression at fill, in the internal ads shape', async () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + renderWithLog( + , + ); + + screen + .getByTestId('adsense-slot-2') + .appendChild(document.createElement('iframe')); + await act(async () => { + await Promise.resolve(); + }); + + const impressions = logEvent.mock.calls.filter( + ([event]) => event.event_name === AdActions.Impression, + ); + expect(impressions).toHaveLength(1); + expect(impressions[0][0]).toMatchObject({ + target_type: 'ad', + target_id: '2222222222', + ad_provider_id: 'adsense', + }); + // The strict name is reserved for the MRC measurement from useViewability. + expect(loggedEvents()).not.toContain(AdActions.Viewable); + }); + + it('logs a same-tab click-through on pagehide', async () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + renderWithLog( + , + ); + + const iframe = document.createElement('iframe'); + screen.getByTestId('adsense-slot-2').appendChild(iframe); + await act(async () => { + await Promise.resolve(); + }); + + iframe.focus(); + fireEvent(window, new Event('pagehide')); + + const clicks = logEvent.mock.calls.filter( + ([event]) => event.event_name === AdActions.Click, + ); + expect(clicks).toHaveLength(1); + expect(JSON.parse(clicks[0][0].extra)).toMatchObject({ + signal: 'pagehide', + }); + }); + + it('disarms a focused creative when the visitor returns without leaving', async () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + renderWithLog( + , + ); + + const iframe = document.createElement('iframe'); + screen.getByTestId('adsense-slot-2').appendChild(iframe); + await act(async () => { + await Promise.resolve(); + }); + + // Tap focuses the creative; the visitor stays, the window regains focus, + // and only minutes later blurs for an unrelated reason (alt-tab). + iframe.focus(); + fireEvent.focus(window); + fireEvent.blur(window); + + expect(loggedEvents()).not.toContain(AdActions.Click); + }); + + it('ignores window blur while focus is outside the creative', async () => { + setSlots({ '2': { id: '2222222222', type: 'display' } }); + renderWithLog( + , + ); + + screen + .getByTestId('adsense-slot-2') + .appendChild(document.createElement('iframe')); + await act(async () => { + await Promise.resolve(); + }); + + fireEvent.blur(window); + + expect(loggedEvents()).not.toContain(AdActions.Click); + }); + + it('logs a push error when adsbygoogle rejects the request', () => { + // Simulates the tag being present but broken (partial ad-block). + window.adsbygoogle = { + push: () => { + throw new Error('adsbygoogle push blocked'); + }, + } as unknown as typeof window.adsbygoogle; + setSlots({ '2': { id: '2222222222', type: 'display' } }); + renderWithLog( + , + ); + window.adsbygoogle = []; + + expect(loggedEvents()).toContain(LogEvent.AdsenseSlotError); + }); +}); diff --git a/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.tsx b/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.tsx new file mode 100644 index 00000000000..3d490b18081 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.tsx @@ -0,0 +1,161 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { isDevelopment } from '../../../lib/constants'; +import { + useOrganicAdsenseSlots, + useReadAdsenseSlots, +} from './useReadAdsenseSlots'; +import type { AdsenseSlots } from '../../../features/monetization/adsense'; +import { hasLiveAdsenseUnits } from '../../../features/monetization/adsense'; +import type { ProgrammaticAdFormat } from '../../../features/monetization/ProgrammaticAd'; +import { + FORMAT_SPEC, + ProgrammaticAd, +} from '../../../features/monetization/ProgrammaticAd'; + +export type ArbitrageAdSurface = 'read' | 'organic'; + +export { + getAdsenseSlotLogExtra, + ProgrammaticAdFormat as ArbitrageAdFormat, +} from '../../../features/monetization/ProgrammaticAd'; + +export interface ArbitrageAdSlotProps { + slot: number; + format: ProgrammaticAdFormat; + className?: string; + /** Marks slots wired to a declared 30-60s in-view refresh once on Ad Manager. */ + refreshes?: boolean; + /** + * Drops the slot below the tablet breakpoint. The Better Ads Standards cap + * mobile ad density at 30% of page height, and a scraped post carries little + * body text to dilute it — running every slot on a phone measured 56%, which + * is what gets a site's ads filtered by Chrome. The unit is hidden rather + * than skipped so it also never requests: the ad only pushes on intersection, + * and a display:none box never intersects. + */ + hideOnPhone?: boolean; + /** + * Which slot map and flag gate the unit: the /read arbitrage template + * (default) or the organic post page. The dashed density-review placeholder + * is a /read-template tool and never renders for the organic surface. + */ + surface?: ArbitrageAdSurface; + /** + * Requests the ad on mount instead of waiting to near the viewport. For + * slots visible at first paint the intersection wait only adds latency — + * and the adsbygoogle array queues pushes before the script has even + * arrived, so eager pushes ride its very first processing pass. + */ + eager?: boolean; +} + +function MappedAdSlot({ + slot, + format, + className, + refreshes, + hideOnPhone, + eager, + slots, + surface, + allowPlaceholder = false, +}: ArbitrageAdSlotProps & { + slots: AdsenseSlots; + surface: ArbitrageAdSurface; + allowPlaceholder?: boolean; +}): ReactElement | null { + const isLive = hasLiveAdsenseUnits(slots); + const config = slots[String(slot)]; + + if (isLive) { + if (!config?.id) { + return null; + } + return ( + // An can only be initialised once, so any change to the unit's + // identity has to remount rather than re-render. + + ); + } + + if (!isDevelopment || !allowPlaceholder) { + return null; + } + + const spec = FORMAT_SPEC[format]; + + return ( +
    + + {slot} + + + {spec.size} + {refreshes ? ' · refreshes' : ''} + + + {spec.label} + + + Ad + +
    + ); +} + +function ReadArbitrageAdSlot(props: ArbitrageAdSlotProps): ReactElement | null { + const slots = useReadAdsenseSlots(); + return ( + + ); +} + +function OrganicArbitrageAdSlot( + props: ArbitrageAdSlotProps, +): ReactElement | null { + const slots = useOrganicAdsenseSlots(); + return ; +} + +/** + * A programmatic ad slot. Live only while its surface's boolean flag is on + * AND its hardcoded map (slots.ts) carries a unit id for this slot number; + * everything else collapses to nothing — visitors get a clean page. The + * dashed density-review placeholder only ever appears in local development + * builds of the /read template. + */ +export function ArbitrageAdSlot({ + surface = 'read', + ...props +}: ArbitrageAdSlotProps): ReactElement | null { + // Split by surface so each branch evaluates only its own slot source: the + // organic hook conditionally evaluates the post_adsense flag, and a /read + // page calling it would enroll every visitor in an experiment that does not + // govern that route. + if (surface === 'organic') { + return ; + } + return ; +} diff --git a/packages/shared/src/components/post/arbitrage/ArbitragePostContent.tsx b/packages/shared/src/components/post/arbitrage/ArbitragePostContent.tsx new file mode 100644 index 00000000000..ce1a201ea29 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/ArbitragePostContent.tsx @@ -0,0 +1,329 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { isVideoPost } from '../../../graphql/posts'; +import { Origin } from '../../../lib/log'; +import usePostContent from '../../../hooks/usePostContent'; +import PostMetadata from '../../cards/common/PostMetadata'; +import PostSourceInfo from '../PostSourceInfo'; +import { PostHeaderActions } from '../PostHeaderActions'; +import { ButtonSize } from '../../buttons/common'; +import { PostTagList } from '../tags/PostTagList'; +import { PostContainer } from '../common'; +import { PostContentContainerRaw } from './common'; +import YoutubeVideo from '../../video/YoutubeVideo'; +import { LazyImage } from '../../LazyImage'; +import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; +import { TruncateText } from '../../utilities'; +import Markdown from '../../Markdown'; +import { ArbitrageAdFormat, ArbitrageAdSlot } from './ArbitrageAdSlot'; +import { ArbitrageTopLeaderboard } from './ArbitrageTopLeaderboard'; +import { + ARBITRAGE_SLOT, + COMMENTS_PER_INTERLEAVED_AD, + TOP_LEADERBOARD_STICKY_MS, +} from './slots'; +import { useTimedRelease } from './useTimedRelease'; +import { GoBackHeaderMobile } from '../GoBackHeaderMobile'; +import { PostWidgets, PostWidgetPosition } from '../PostWidgets'; +import PostEngagements from '../PostEngagements'; + +/** + * One slot per real rail widget, in render order. The rail is the page's only + * column with no article in it, so every widget there earns a unit; the two + * that are already commercial (the house ad widget and the sponsored tools + * card) have no position and so get none. + * + * Below laptop the rail stacks under the article rather than beside it, so + * an unfiltered run lands every unit on a phone too — measured at roughly 40% + * of page height against the Better Ads Standards' 30% mobile cap, and + * Chrome's ad filter for a violation applies to the whole domain, direct-sold + * inventory included. Only the first rail unit keeps its phone placement; the + * rest are desktop-only, which brings the phone run well under the cap. + */ +const RAIL_AD: Record< + PostWidgetPosition, + { + slot: number; + format: ArbitrageAdFormat; + className?: string; + hideOnPhone?: boolean; + } +> = { + [PostWidgetPosition.Source]: { + slot: ARBITRAGE_SLOT.railAfterSource, + format: ArbitrageAdFormat.MediumRectangle, + }, + [PostWidgetPosition.Creator]: { + slot: ARBITRAGE_SLOT.railAfterCreator, + format: ArbitrageAdFormat.MediumRectangle, + hideOnPhone: true, + }, + [PostWidgetPosition.Share]: { + slot: ARBITRAGE_SLOT.railAfterShare, + format: ArbitrageAdFormat.MediumRectangle, + hideOnPhone: true, + }, + [PostWidgetPosition.Highlights]: { + slot: ARBITRAGE_SLOT.railAfterHighlights, + format: ArbitrageAdFormat.MediumRectangle, + hideOnPhone: true, + }, + // Between "You might like" and the discussions, and the only unit that + // stays with the visitor: it pins under the fixed chrome and rides the rest + // of the scroll. Sticky is bounded by the containing block, which must be + // the rail itself — stretched to the article column's height — for the unit + // to have the whole page to travel; FurtherReading flattens to `contents` + // around it for exactly that reason, and any wrapper that generates a box + // here would cut the travel to that box. z-1 puts it over the widgets that + // scroll underneath, and the background keeps them from showing through the + // space the creative does not fill. + [PostWidgetPosition.SimilarPosts]: { + slot: ARBITRAGE_SLOT.railBetweenFurtherReading, + format: ArbitrageAdFormat.MediumRectangle, + className: + 'laptop:sticky laptop:top-[calc(var(--sticky-header-offset)+1rem)] laptop:z-1 laptop:bg-background-default', + hideOnPhone: true, + }, +}; + +export interface ArbitragePostContentProps { + post: Post; + className?: string; +} + +/** + * Ad-monetised post template for paid and organic landing traffic. + * + * Forked from the classic PostContent layout rather than the focus card: for + * scraped articles neither template has a body to render, so the focus card's + * only real advantage does not apply, while the widget column it lacks carries + * three always-viewable slots. Signup surfaces (PostAuthBanner, + * CustomAuthBanner, PostSignupWidget) are deliberately absent — the header + * login/signup buttons remain the only account entry point. + * + * A fork rather than variant props on PostContent: threading a dozen slot + * positions and removed surfaces through the production component would put + * ad concerns in every consumer's render path (modals and the extension + * included) for a template that may not survive its experiment. The cost is + * accepted, not free — fixes to PostContent's column structure must be + * mirrored here, and if /read wins, folding this back is the follow-up debt. + */ +export function ArbitragePostContent({ + post, + className, +}: ArbitragePostContentProps): ReactElement { + const isVideoType = isVideoPost(post); + const { onReadArticle, onCopyPostLink } = usePostContent({ + origin: Origin.ArticlePage, + post, + }); + const leaderboardReleased = useTimedRelease(TOP_LEADERBOARD_STICKY_MS); + + return ( + + {/* PostContainer is overflow-hidden, which would make it the scroll + container for the leaderboard's sticky position and stop it pinning. + overflow-x: clip alongside overflow-y: visible clips the column the + same way without creating a scroll container. */} + + {/* Below laptop the leaderboard and the production mobile header pin + as one block, so the header cannot ride up over the ad the way it + did when each was sticky on its own. The header's own sticky is off + while the block is pinned, or it would climb to the top of it and + land on the leaderboard anyway. + + Once the ten second window closes the block releases: the ad + scrolls away with the page and the header, back on its own sticky, + takes the top over. `contents` rather than a class swap because the + header's sticky is bounded by its containing block, and a wrapper + that still generated a box would let it pin only as far as the + wrapper's own few pixels of height. + + Transparent from laptop up, where the header does not render and + the leaderboard pins itself against the fixed chrome instead. */} +
    + + + + + +
    + +
    +
    + +
    +

    + + {post.title} + +

    +
    + + {isVideoType && ( + undefined }} + videoId={post.videoId ?? ''} + className="mb-7" + /> + )} + + {!!post.summary && ( +
    +

    + {post.summary} +

    +
    + )} + + {/* MPU 1 beside the tags, date and cover rather than above them, so the + first ad shares the fold with real page furniture instead of + standing alone. The slot is first in the DOM because a phone stacks + the column and the brief puts the unit above the article, not below + it; from laptop `order-last` moves it to the right of the group. + + The two halves are deliberately near equal — 336 for the unit + against 385 for the article's, out of the column's 745 — so the ad + reads as the cover's counterpart rather than as a tower beside it. + items-end puts their bottom edges on the same line. */} +
    + + +
    + + + From{' '} + + {post.domain} + + + ) + } + /> + + {!isVideoType && ( + + + + )} +
    +
    + + {!!post.contentHtml && ( + globalThis?.document?.body} + /> + )} + + {/* The production engagement block verbatim — counts, actions, share, + sort control, composer and thread — so everything from here to the + end of the discussion matches the live post page exactly. The only + addition is a native unit every few comments in a long thread. */} + ( + + )} + /> +
    + + {/* The production widget column, minus the signup card and the table of + contents, with a slot after every widget that actually renders. */} + { + const spec = RAIL_AD[position]; + + return ( + + ); + }} + /> +
    + ); +} diff --git a/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.spec.tsx b/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.spec.tsx new file mode 100644 index 00000000000..f9e7f5df8c1 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.spec.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { act, render, renderHook } from '@testing-library/react'; +import { ArbitrageTopLeaderboard } from './ArbitrageTopLeaderboard'; +import { useTimedRelease } from './useTimedRelease'; +import { useFeature } from '../../GrowthBookProvider'; +import { TOP_LEADERBOARD_STICKY_MS } from './slots'; + +jest.mock('../../GrowthBookProvider', () => ({ + ...(jest.requireActual('../../GrowthBookProvider') as Record< + string, + unknown + >), + useFeature: jest.fn(), +})); + +const mockUseFeature = jest.mocked(useFeature); + +const scroll = (): void => { + act(() => { + globalThis.dispatchEvent(new Event('scroll')); + }); +}; + +const advancePastStickyWindow = (): void => { + act(() => { + jest.advanceTimersByTime(TOP_LEADERBOARD_STICKY_MS); + }); +}; + +// Pinning is the laptop variant now: below it the unit pins as part of the +// header block its parent wraps around, not on its own. +const isPinned = (container: HTMLElement): boolean => + !!container.firstElementChild?.classList.contains('laptop:sticky'); + +beforeEach(() => { + jest.useFakeTimers(); + mockUseFeature.mockReturnValue({}); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('ArbitrageTopLeaderboard', () => { + it('pins from laptop up until it is released', () => { + const { container } = render(); + + expect(isPinned(container)).toBe(true); + }); + + it('scrolls away with the page once released', () => { + const { container } = render(); + + expect(isPinned(container)).toBe(false); + }); +}); + +describe('useTimedRelease', () => { + it('holds the sticky window open while the visitor has not scrolled yet', () => { + const { result } = renderHook(() => + useTimedRelease(TOP_LEADERBOARD_STICKY_MS), + ); + advancePastStickyWindow(); + + expect(result.current).toBe(false); + }); + + it('releases once the window elapses after the first scroll', () => { + const { result } = renderHook(() => + useTimedRelease(TOP_LEADERBOARD_STICKY_MS), + ); + scroll(); + expect(result.current).toBe(false); + + advancePastStickyWindow(); + + expect(result.current).toBe(true); + }); +}); diff --git a/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.tsx b/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.tsx new file mode 100644 index 00000000000..ffb3c977f06 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.tsx @@ -0,0 +1,57 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ArbitrageAdFormat, ArbitrageAdSlot } from './ArbitrageAdSlot'; +import { ARBITRAGE_SLOT } from './slots'; + +export interface ArbitrageTopLeaderboardProps { + /** False while the unit is still inside its sticky window. */ + released: boolean; +} + +/** + * Top leaderboard (slot 2), first thing in the article column. The column is + * 745px wide inside its padding at the layout's full width, so a 728px + * leaderboard renders at its booked size within the page rather than spanning + * it; narrower viewports get the 320x100 large mobile banner instead. + * + * Stays pinned for the first ten seconds of scrolling, then releases and + * scrolls away with the page. Sticky rather than fixed so it only pins within + * the article column and can never overlap the rail. + */ +export function ArbitrageTopLeaderboard({ + released, +}: ArbitrageTopLeaderboardProps): ReactElement { + return ( +
    + +
    + ); +} diff --git a/packages/shared/src/components/post/arbitrage/common.tsx b/packages/shared/src/components/post/arbitrage/common.tsx new file mode 100644 index 00000000000..8f4907eadd3 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/common.tsx @@ -0,0 +1,10 @@ +import classed from '../../../lib/classed'; + +/** + * Same two-column shell the classic post layout uses, without the fixed + * navigation branch — this template never enters modal/navigation mode. + */ +export const PostContentContainerRaw = classed( + 'div', + 'm-auto flex w-full flex-col bg-background-default pb-6 laptop:flex-row laptop:border-x laptop:border-border-subtlest-tertiary', +); diff --git a/packages/shared/src/components/post/arbitrage/slots.ts b/packages/shared/src/components/post/arbitrage/slots.ts new file mode 100644 index 00000000000..0a809017317 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/slots.ts @@ -0,0 +1,135 @@ +/** + * Slot numbers named with the ad partner's own terminology, so the remote + * config, this code and their placement brief all refer to the same units. + * + * IAB names: a "leaderboard" is 728x90, an "MPU" (mid page unit) is the + * 300x250 / 336x280 rectangle, and a "half page" is 300x600. + */ +import type { AdsenseSlots } from '../../../features/monetization/adsense'; + +export const ARBITRAGE_SLOT = { + /** Leaderboard above the article. Sticks while scrolling, then releases. */ + topLeaderboard: 2, + /** Medium rectangle beside the tags, date and cover image. */ + inlineMpu1: 3, + /** Rail unit after the author card. */ + railAfterCreator: 4, + /** Rail unit after the share bar. */ + railAfterShare: 5, + /** Rail unit after the highlights widget. */ + railAfterHighlights: 6, + /** Native unit, repeated through a long comment thread. */ + commentNative: 7, + /** "MPU 1" in the brief: first rail unit, under the source card. */ + railAfterSource: 11, + /** Sticky rail unit after the further reading widget. */ + railBetweenFurtherReading: 12, +} as const; + +/* + * Slot numbers 1, 8, 9, 10 and 13 are retired rather than reused: the sidebar + * unit, the two closing multiplex grids, the half-page rail tower and the + * custom floating leaderboard were all dropped, and their AdSense reporting + * rows stay readable only while no other placement inherits the number. + * + * The bottom leaderboard is Google's Anchor format now, not a slot in this + * map: a publisher-implemented sticky is capped at 300px wide and desktop + * only, while Google's own anchor serves a full-width leaderboard on every + * breakpoint and renders its own dismiss control — which must never be + * hidden, styled or covered. + * TODO(chris): enable "Anchor ads" under Auto ads in the AdSense account, + * with BOTH preconditions met first: + * 1. Scope it with an AdSense URL group to /articles only. The script also + * loads on /posts/[id] whenever post_adsense is on, so an unscoped + * account-level anchor lands on the organic post page too, on top of the + * two reviewed units there. + * 2. Verify the anchor against FooterNavBarLayout's fixed bottom bar on a + * real phone before traffic. The custom anchor's offsetByAnchorAd + * compensation retired with it; if Google's overlay and the bar collide, + * an obscured ad is itself a policy violation. + */ + +/** + * The top leaderboard stays pinned this long while the visitor scrolls, then + * releases and scrolls away with the page. Partner spec. + */ +export const TOP_LEADERBOARD_STICKY_MS = 10_000; + +/** + * A long thread gets a native unit after every this many comments. Short + * threads never reach the interval, so they stay entirely ad-free. + */ +export const COMMENTS_PER_INTERLEAVED_AD = 5; + +/** + * The AdSense units behind each slot, keyed by slot number. Deliberately in + * code rather than remote config: unit ids are public (visible in the page + * source of any live page) and stable after setup, and as a GrowthBook JSON + * value they shipped in every boot payload of every surface. The remote side + * is just the `post_adsense` boolean; the /read template needs none. + * + * A unit's `type` is set when it is created in AdSense and cannot be + * overridden from here: an in-article unit is fluid whatever the asks + * for, which is why slot 3 was answering a 300x250 placement with a 600px + * tall creative. Where the two disagree the TODO says which unit to recreate. + */ +export const READ_ADSENSE_SLOTS: AdsenseSlots = { + [ARBITRAGE_SLOT.topLeaderboard]: { id: '9942870945', type: 'display' }, + // read_s03 (9651332107) is an in-article unit, so it is fluid: it ignored + // both the shape and an explicit 300x250 on the and kept answering the + // placement beside the cover with a card twice the cover's height. Pointing + // it at a responsive Display unit is what actually binds the shape — at the + // cost of blending its reporting with the rail unit it borrows. + // TODO(chris): create a dedicated read_s03 Display unit and swap the id back + // to get per-placement RPM. + [ARBITRAGE_SLOT.inlineMpu1]: { id: '6921226982', type: 'display' }, + // TODO(chris): create the three new rail units (suggested names + // read_s04_rail_creator, read_s05_rail_share, read_s06_rail_highlights) as + // Display 300x250. They stay collapsed until their ids are filled in. + [ARBITRAGE_SLOT.railAfterCreator]: { id: '', type: 'display' }, + [ARBITRAGE_SLOT.railAfterShare]: { id: '', type: 'display' }, + [ARBITRAGE_SLOT.railAfterHighlights]: { id: '', type: 'display' }, + // TODO(chris): layoutKey from the read_s07_comment_native "Get code" snippet + // (data-ad-layout-key). The slot stays collapsed until it is filled in. + // + // PRECONDITIONS on filling this in — this comment is the gate, since the + // workflow is "ship reviewed once, switch on by editing this map": + // 1. Ad label: DONE in code — ProgrammaticAd renders the policy-permitted + // "Advertisements" caption above every inFeed unit, so an unlabeled + // native between comments cannot ship by omission. + // 2. Re-measure phone ad density on a long thread with the interval live. + // The ~27% figure was measured with this slot inert, it is the only + // repeating slot on the page, and Chrome's Better Ads filter applies to + // the whole domain, direct-sold inventory included. + [ARBITRAGE_SLOT.commentNative]: { id: '', type: 'inFeed', layoutKey: '' }, + [ARBITRAGE_SLOT.railAfterSource]: { id: '5249052667', type: 'display' }, + [ARBITRAGE_SLOT.railBetweenFurtherReading]: { + id: '6921226982', + type: 'display', + }, +}; + +/** + * The organic post page (/posts/[id]) carries two units, gated by the + * `post_adsense` boolean and hidden from Plus members like the internal ads. + * Numbered after the /read range so reports never collide. + */ +export const ORGANIC_SLOT = { + /** Leaderboard above the post content, spanning the page column. */ + topLeaderboard: 15, + /** Half page closing the widget column, sticky for the rest of the read. */ + railHalfPage: 16, +} as const; + +// TODO(chris): create the two organic units in AdSense (suggested names +// post_s15_top_leaderboard, post_s16_rail_half_page) and fill in the ids. +// Both slots stay collapsed until then. +export const ORGANIC_ADSENSE_SLOTS: AdsenseSlots = { + [ORGANIC_SLOT.topLeaderboard]: { id: '', type: 'display' }, + [ORGANIC_SLOT.railHalfPage]: { + id: '', + type: 'display', + width: 300, + height: 600, + }, +}; diff --git a/packages/shared/src/components/post/arbitrage/useReadAdsenseSlots.ts b/packages/shared/src/components/post/arbitrage/useReadAdsenseSlots.ts new file mode 100644 index 00000000000..1845d70dff5 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/useReadAdsenseSlots.ts @@ -0,0 +1,62 @@ +import { useContext } from 'react'; +import { + featurePostAdsense, + featureReadAdsense, +} from '../../../lib/featureManagement'; +import AuthContext from '../../../contexts/AuthContext'; +import { isDevelopment } from '../../../lib/constants'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { useFeature } from '../../GrowthBookProvider'; +import type { AdsenseSlots } from '../../../features/monetization/adsense'; +import { hasLiveAdsenseUnits } from '../../../features/monetization/adsense'; +import { ORGANIC_ADSENSE_SLOTS, READ_ADSENSE_SLOTS } from './slots'; + +const NO_SLOTS: AdsenseSlots = {}; + +/** + * Anonymous is a post-boot fact, not the absence of a user object: `user` is + * undefined until boot resolves, so reading it early classifies every + * logged-in visitor as anonymous for a moment — long enough to request an ad + * that then has to be torn back out of a Plus member's page. No provider (a + * bare component test) is never anonymous. + */ +const useIsAnonymous = (): boolean => { + const auth = useContext(AuthContext); + return !!auth?.isAuthReady && !auth?.user; +}; + +/** + * The /read template's units. Anonymous visitors only — the page exists for + * paid-acquisition traffic, and ad-free is part of what Plus members pay for. + * The `read_adsense` flag is an emergency kill switch, on by default; there + * is no ramp. Development builds get the dashed density placeholders instead + * of live units, hence the empty map there. + */ +export const useReadAdsenseSlots = (): AdsenseSlots => { + const isAnonymous = useIsAnonymous(); + const enabled = useFeature(featureReadAdsense); + + if (isDevelopment) { + return NO_SLOTS; + } + + return enabled && isAnonymous ? READ_ADSENSE_SLOTS : NO_SLOTS; +}; + +/** + * The organic post page's units: only while the `post_adsense` flag is on, + * and only for anonymous visitors — any logged-in user (member or Plus) + * never sees programmatic ads on their post pages. + */ +export const useOrganicAdsenseSlots = (): AdsenseSlots => { + const isAnonymous = useIsAnonymous(); + // Conditional evaluation, because evaluating enrolls: a visitor who is + // logged in — or whose units have no AdSense id yet and so cannot render + // anything — would fill the experiment with byte-identical variants. + const { value: enabled } = useConditionalFeature({ + feature: featurePostAdsense, + shouldEvaluate: isAnonymous && hasLiveAdsenseUnits(ORGANIC_ADSENSE_SLOTS), + }); + + return enabled && isAnonymous ? ORGANIC_ADSENSE_SLOTS : NO_SLOTS; +}; diff --git a/packages/shared/src/components/post/arbitrage/useTimedRelease.ts b/packages/shared/src/components/post/arbitrage/useTimedRelease.ts new file mode 100644 index 00000000000..c025e56f4a4 --- /dev/null +++ b/packages/shared/src/components/post/arbitrage/useTimedRelease.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; + +/** + * True once `delayMs` has elapsed since the visitor's first scroll — the top + * leaderboard is meant to stay pinned for the first ten seconds of *reading*. + * Counting from mount instead means a visitor who studies the headline for + * ten seconds has spent the whole window before their first scroll, so the + * unit never pins for them and the placement looks broken. A page nobody + * scrolls never elapses, which costs nothing: the unit sits at its natural + * position, where pinned and unpinned look identical. + */ +export function useTimedRelease(delayMs: number): boolean { + const [released, setReleased] = useState(false); + + useEffect(() => { + if (delayMs <= 0) { + setReleased(true); + return undefined; + } + + let timer: ReturnType | undefined; + const startCountdown = (): void => { + timer = globalThis.setTimeout(() => setReleased(true), delayMs); + }; + + globalThis.addEventListener('scroll', startCountdown, { + passive: true, + once: true, + }); + + return () => { + globalThis.removeEventListener('scroll', startCountdown); + if (timer) { + globalThis.clearTimeout(timer); + } + }; + }, [delayMs]); + + return released; +} diff --git a/packages/shared/src/components/post/common.tsx b/packages/shared/src/components/post/common.tsx index 8f4fd8777da..c6658bc0ad7 100644 --- a/packages/shared/src/components/post/common.tsx +++ b/packages/shared/src/components/post/common.tsx @@ -89,6 +89,12 @@ export interface PostContentProps position?: CSSProperties['position']; backToSquad?: boolean; isPostPage?: boolean; + /** + * Rendered as the widget column's last child. Only the webapp post page + * passes it (an AdSense unit) — post modals and the extension must never, + * as the ad script only exists on the page and AdSense bans extensions. + */ + widgetsTrailing?: ReactNode; } export const PostContainer = classed( diff --git a/packages/shared/src/components/post/focus/PostFocusCard.spec.tsx b/packages/shared/src/components/post/focus/PostFocusCard.spec.tsx new file mode 100644 index 00000000000..ef108780032 --- /dev/null +++ b/packages/shared/src/components/post/focus/PostFocusCard.spec.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import post, { + postWithCommunitySentiment, + sharePost, +} from '../../../../__tests__/fixture/post'; +import type { Post } from '../../../graphql/posts'; +import { PostType } from '../../../graphql/posts'; +import { Origin } from '../../../lib/log'; +import { featureCommunitySentiment } from '../../../lib/featureManagement'; +import { getPostByIdKey } from '../../../lib/query'; +import { PostFocusCard } from './PostFocusCard'; + +const freeformPost: Post = { + ...post, + id: 'freeform-post-id', + type: PostType.Freeform, + contentHtml: '

    Freeform body

    ', +}; + +const sharedFreeformPost: Post = { + ...sharePost, + id: 'shared-freeform-id', + sharedPost: { + ...sharePost.sharedPost, + type: PostType.Freeform, + }, +} as Post; + +const renderCard = ( + postToRender: Post, + options: { + gb?: GrowthBook; + onClose?: () => void; + client?: QueryClient; + } = {}, +) => + render( + + + , + ); + +describe('PostFocusCard opening the source article', () => { + it('links the cover to the source article on an external post', () => { + renderCard(post); + + const cover = screen.getByTestId('post-cover-link'); + expect(cover).toHaveAttribute('href', post.permalink); + expect(cover).toHaveAttribute('target', '_blank'); + expect(cover).toHaveAttribute('aria-hidden', 'true'); + expect(cover).toHaveAttribute('tabindex', '-1'); + expect(screen.queryByLabelText('View cover image')).not.toBeInTheDocument(); + }); + + it('links the cover to the shared article on a share post', () => { + renderCard(sharePost); + + expect(screen.getByTestId('post-cover-link')).toHaveAttribute( + 'href', + sharePost.sharedPost?.permalink, + ); + }); + + it('links the title to the source article on an external post', () => { + renderCard(post); + + const title = screen.getByTestId('post-modal-title'); + expect(title.querySelector('a')).toHaveAttribute('href', post.permalink); + }); + + it('keeps the lightbox when a share wraps a native post', () => { + renderCard(sharedFreeformPost); + + expect(screen.queryByTestId('post-cover-link')).not.toBeInTheDocument(); + expect(screen.getByLabelText('View cover image')).toBeInTheDocument(); + expect( + screen.getByTestId('post-modal-title').querySelector('a'), + ).toBeNull(); + }); + + it('keeps the lightbox and a plain title on a native post', () => { + renderCard(freeformPost); + + expect(screen.queryByTestId('post-cover-link')).not.toBeInTheDocument(); + expect(screen.getByLabelText('View cover image')).toBeInTheDocument(); + expect( + screen.getByTestId('post-modal-title').querySelector('a'), + ).toBeNull(); + }); +}); + +describe('PostFocusCard community sentiment', () => { + it('renders in the post modal when the flag is enabled', () => { + const gb = new GrowthBook(); + gb.setFeatures({ + [featureCommunitySentiment.id]: { + defaultValue: true, + }, + }); + + renderCard(postWithCommunitySentiment, { gb, onClose: jest.fn() }); + + expect( + screen.getByRole('region', { name: 'What the community thinks' }), + ).toBeInTheDocument(); + expect(screen.getByText('Most agree it is worth reading.')).toBeVisible(); + }); + + it('hydrates the take from the post-by-id cache when the feed post omits it', () => { + const gb = new GrowthBook(); + gb.setFeatures({ + [featureCommunitySentiment.id]: { + defaultValue: true, + }, + }); + // Feed payloads omit `communitySentiment`, so the modal must read the + // hydrated post from the post-by-id cache instead of the feed prop. + const client = new QueryClient(); + client.setQueryData(getPostByIdKey(postWithCommunitySentiment.id), { + post: postWithCommunitySentiment, + }); + const feedPost: Post = { + ...postWithCommunitySentiment, + communitySentiment: undefined, + }; + + renderCard(feedPost, { gb, client, onClose: jest.fn() }); + + expect( + screen.getByRole('region', { name: 'What the community thinks' }), + ).toBeInTheDocument(); + }); + + it('stays hidden in the post modal when the flag is disabled', () => { + renderCard(postWithCommunitySentiment, { onClose: jest.fn() }); + + expect( + screen.queryByRole('region', { name: 'What the community thinks' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 9146d51091c..54d2eaea0bc 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -34,7 +34,7 @@ import { getReadPostButtonIcon } from '../../cards/common/ReadArticleButton'; import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; import { PostTagList } from '../tags/PostTagList'; import { TruncateText } from '../../utilities'; -import { combinedClicks } from '../../../lib/click'; +import { combinedClicks, withSelectionGuard } from '../../../lib/click'; import { useFeature } from '../../GrowthBookProvider'; import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; import { @@ -53,6 +53,11 @@ import { FollowButton } from '../../contentPreference/FollowButton'; import { ContentPreferenceType } from '../../../graphql/contentPreference'; import { PostSidebarAdWidget } from '../PostSidebarAdWidget'; import { PostMenuOptions } from '../PostMenuOptions'; +import { PostAnsweredQuestions } from '../PostAnsweredQuestions'; +import { SnapshotButton } from '../../imageShare/SnapshotButton'; +import { PostSnapshotCard } from '../../../features/snapshot/PostSnapshotCard'; +import { SNAPSHOT_SIZE } from '../../../features/snapshot/snapshotGradient'; +import { withPostById } from '../withPostById'; import { FocusCardActionBar } from './FocusCardActionBar'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; @@ -215,7 +220,7 @@ const VideoSummary = ({ summary }: { summary: string }): ReactElement => { ); }; -export const PostFocusCard = ({ +const PostFocusCardRaw = ({ post, origin, leftVariant, @@ -250,7 +255,7 @@ export const PostFocusCard = ({ const { onReadClick: onReaderInstallGateClick } = useReaderInstallPromptGate(post); const { isReaderEnabled } = useReaderModalEligibility(); - const isReaderVariant = isReaderEnabled && post.type === PostType.Article; + const isReaderVariant = isReaderEnabled && article.type === PostType.Article; const showCodeSnippets = useFeature(feature.showCodeSnippets); const communitySentimentData = article.communitySentiment ? mapCommunitySentimentPost(article.communitySentiment) @@ -263,14 +268,11 @@ export const PostFocusCard = ({ feature: featureCommunitySentiment, shouldEvaluate: !!communitySentimentData, }); - // Only on the full post page, not the preview modal (which passes - // `onClose`), and only when the post actually has a take. `isDevelopment` - // lets the surface be previewed locally without flipping the committed - // (always-`false`) flag default. + // Only when the post actually has a take. `isDevelopment` lets the surface be + // previewed locally without flipping the committed (always-`false`) flag + // default. const showCommunitySentiment = - !onClose && - !!communitySentimentData && - (communitySentimentEnabled || isDevelopment); + !!communitySentimentData && (communitySentimentEnabled || isDevelopment); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the @@ -279,8 +281,10 @@ export const PostFocusCard = ({ // instead we detect the click landing inside the cross-origin iframe via the // window losing focus to it, then animate the container open. const videoWrapperRef = useRef(null); + const snapshotRef = useRef(null); const [isVideoExpanded, setIsVideoExpanded] = useState(false); const readHref = getReadArticleHref(post); + const canReadArticle = !!readHref && !isInternalReadType(article); useTrackPostView({ post }); @@ -326,22 +330,38 @@ export const PostFocusCard = ({ // to the title regardless of the cover image height. The engagement bar lives // further down by the comment composer where the reader's cursor rests. const renderReadButton = (className: string): ReactElement | null => - readHref && !isInternalReadType(post) ? ( + canReadArticle ? ( ) : null; + const coverClassName = + 'block h-fit w-24 shrink-0 overflow-hidden rounded-16 bg-background-subtle tablet:w-40'; + const coverImage = + !isVideoType && article.image ? ( + 25/13). + className="aspect-square w-full tablet:aspect-[25/13]" + fallbackSrc={cloudinaryPostImageCoverPlaceholder} + fetchPriority="high" + imgAlt="Post cover image" + imgSrc={article.image} + /> + ) : null; + return (
    -
    - {sharedVia && ( -

    - Shared via - - +

    + {sharedVia && ( +

    + Shared via + + + + {sharedVia.image && ( + + )} + {sharedVia.name} + + + + } + > + + +

    + )} + {isShared && !sharedVia && ( +

    Shared post

    + )} + {!isShared && isCollection && ( +

    Collection

    + )} + {/* Title and image are top-aligned columns. The read button lives + in the title column (right under the title) so it hugs the title + regardless of the image height — a short title next to a tall + image keeps the button close instead of dragging it down. */} +
    +
    +

    + {canReadArticle ? ( + ( + withSelectionGuard(handleReadClick), + )} + className="transition-colors hover:text-text-link" > - - {sharedVia.image && ( - - )} - {sharedVia.name} - - - - } - > - - -

    - )} - {isShared && !sharedVia && ( -

    Shared post

    - )} - {!isShared && isCollection && ( -

    Collection

    - )} - {/* Title and image are top-aligned columns. The cover image opens a - lightbox rather than navigating away. The read button lives in - the title column (right under the title) so it hugs the title - regardless of the image height — a short title next to a tall - image keeps the button close instead of dragging it down. */} -
    -
    -

    - {title} -

    - {renderReadButton('w-fit')} + {title} + + ) : ( + title + )} +

    + {renderReadButton('w-full tablet:w-fit')} +
    + {coverImage && + (canReadArticle ? ( + (handleReadClick)} + aria-hidden + tabIndex={-1} + data-testid="post-cover-link" + className={classNames(coverClassName, 'cursor-pointer')} + > + {coverImage} + + ) : ( + + ))}
    - {!isVideoType && article.image && ( - - )}
    -
    - 0 && ( - - From{' '} - - {article.domain} - - - ) - } - isVideoType={isVideoType} - readTime={article.readTime} - /> + 0 && ( + + From{' '} + + {article.domain} + + + ) + } + isVideoType={isVideoType} + readTime={article.readTime} + /> - {isVideoType && ( -
    - {/* Embed YouTube's native player directly so the first click - plays inside the iframe with sound — no custom overlay or - muted autoplay. */} - -
    - )} + {isVideoType && ( +
    + {/* Embed YouTube's native player directly so the first click + plays inside the iframe with sound — no custom overlay or + muted autoplay. */} + +
    + )} - {article.contentHtml ? ( - <> - - - - ) : ( - article.summary && - (isVideoType ? ( - + {article.contentHtml ? ( + <> + + + ) : ( -

    - {article.summary} -

    - )) - )} + article.summary && + (isVideoType ? ( + + ) : ( +

    + {article.summary} +

    + )) + )} +
    + +
    + +
    + + @@ -592,6 +654,8 @@ export const PostFocusCard = ({ className="-mt-2" /> + {!onClose && } +
    ); }; + +// Feed-opened modals hand over the feed's lighter post payload, which omits +// fields like `communitySentiment`. Hydrating from the post-by-id cache keeps +// the modal and the standalone post page rendering the same surfaces. +export const PostFocusCard = withPostById(PostFocusCardRaw); diff --git a/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx b/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx index 473bde8565a..ee458f0e1de 100644 --- a/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx +++ b/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx @@ -11,6 +11,7 @@ import { TargetId } from '../../../lib/log'; type ArticleReaderFrameProps = { post: Post; targetUrl: string | null; + previewHost?: string; isEmbeddable: boolean; className?: string; onClose?: () => void; @@ -38,6 +39,7 @@ type ArticleReaderFrameProps = { export function ArticleReaderFrame({ post, targetUrl, + previewHost, isEmbeddable, className, onClose, @@ -91,7 +93,7 @@ export function ArticleReaderFrame({ > void; onNextPost?: () => void; @@ -57,6 +58,7 @@ type ReaderPostLayoutProps = { export function ReaderPostLayout({ post: initialPost, + targetPost = initialPost, postPosition, onPreviousPost, onNextPost, @@ -70,12 +72,12 @@ export function ReaderPostLayout({ // icons keep showing the initial prop's state. const { post: cachedPost } = usePostById({ id: initialPost?.id }); const post = cachedPost ?? initialPost; - const { targetUrl, isEmbeddable } = useIframeEmbed(post.permalink); + const { targetUrl, isEmbeddable } = useIframeEmbed(targetPost.permalink); const { logEvent } = useLogContext(); const { openNewTab } = useContext(SettingsContext); const surface = isPostPage ? Origin.ArticlePage : Origin.ArticleModal; const onReadArticle = useReadArticle({ post, origin: surface }); - const readArticleHref = getReadArticleHref(post); + const readArticleHref = getReadArticleHref(targetPost); const hasEmbed = !!targetUrl && isEmbeddable; useEffect(() => { @@ -203,6 +205,7 @@ export function ReaderPostLayout({ { @@ -256,33 +258,35 @@ export function Header({
    {isSameUser && ( <> - - - -
    - + + +
    + + )} {isLoggedIn && user && ( diff --git a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx index 26545cd107e..6efd81de6f4 100644 --- a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx +++ b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx @@ -10,6 +10,7 @@ import { HashtagIcon, HotIcon, TourIcon, + WorldIcon, } from '../../icons'; import { MedalIcon } from '../../icons/Medal'; import { Section } from '../Section'; @@ -84,6 +85,14 @@ export const DiscoverSection = ({ path: `${webappUrl}users`, isForcedLink: true, }, + { + icon: (active: boolean) => ( + } /> + ), + title: 'Worlds', + path: `${webappUrl}world`, + isForcedLink: true, + }, { icon: (active: boolean) => ( } /> diff --git a/packages/shared/src/components/sidebar/sections/MainSection.tsx b/packages/shared/src/components/sidebar/sections/MainSection.tsx index e4277d98246..ffcf537979e 100644 --- a/packages/shared/src/components/sidebar/sections/MainSection.tsx +++ b/packages/shared/src/components/sidebar/sections/MainSection.tsx @@ -27,14 +27,12 @@ import useCustomDefaultFeed from '../../../hooks/feed/useCustomDefaultFeed'; import { SharedFeedPage } from '../../utilities'; import { isExtension } from '../../../lib/func'; import { useConditionalFeature } from '../../../hooks'; -import { - DailyPageVariant, - featureDailyPage, - featureYearInReview, -} from '../../../lib/featureManagement'; +import { featureYearInReview } from '../../../lib/featureManagement'; import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant'; import { useQuestDashboard } from '../../../hooks/useQuestDashboard'; import { Typography, TypographyColor } from '../../typography/Typography'; +import { usePlusSale } from '../../../hooks/usePlusSale'; +import { PlusSaleLabel } from '../../plus/PlusSaleLabel'; export const MainSection = ({ isItemsButton, @@ -45,16 +43,12 @@ export const MainSection = ({ const { isCustomDefaultFeed } = useCustomDefaultFeed(); const { isV2 } = useLayoutVariant(); const isPlus = user?.isPlus; + const { isActive: isSaleActive } = usePlusSale(); const ctaCopy = { full: 'Get API Access', short: 'API access' }; const { value: showYearInReview } = useConditionalFeature({ feature: featureYearInReview, shouldEvaluate: isLoggedIn, }); - const { value: dailyVariant } = useConditionalFeature({ - feature: featureDailyPage, - shouldEvaluate: isLoggedIn, - }); - const showDailyPage = dailyVariant === DailyPageVariant.V1; const { data: questDashboard } = useQuestDashboard(); const claimableMilestoneCount = useMemo( () => @@ -106,6 +100,7 @@ export const MainSection = ({ color: 'text-action-plus-default', itemClassName: 'bg-action-plus-float/50 hover:bg-action-plus-float', disableDefaultBackground: true, + ...(isSaleActive && { rightIcon: () => }), } : undefined; @@ -139,19 +134,6 @@ export const MainSection = ({ } : undefined; - const daily = - isLoggedIn && showDailyPage - ? { - icon: (active: boolean) => ( - } /> - ), - title: 'Daily', - path: `${webappUrl}daily`, - isForcedLink: true, - requiresLogin: true, - } - : undefined; - const yearInReview = showYearInReview ? { icon: () => } />, @@ -179,7 +161,6 @@ export const MainSection = ({ return ( [ myFeed, - daily, { title: 'Following', // this path can be opened on extension so it purposly @@ -221,9 +202,9 @@ export const MainSection = ({ isCustomDefaultFeed, isLoggedIn, isPlus, + isSaleActive, isV2, onNavTabClick, - showDailyPage, showYearInReview, user, ]); diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx index caff0ab76ad..2d7eeb8469f 100644 --- a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -28,6 +28,8 @@ import { } from '../../typography/Typography'; import { PlusUser } from '../../PlusUser'; import { SidebarProfileStats } from '../SidebarProfileStats'; +import { usePlusSale } from '../../../hooks/usePlusSale'; +import { PlusSaleLabel } from '../../plus/PlusSaleLabel'; // The avatar tab panel. Everything "you": identity + your feeds/activity, your // pinned squads and custom feeds. Account/app controls live in the bottom @@ -39,6 +41,7 @@ export const ProfilePanelSection = ({ }: SidebarSectionProps): ReactElement | null => { const { user } = useAuthContext(); const { isPlus, logSubscriptionEvent } = usePlusSubscription(); + const { isActive: isSaleActive } = usePlusSale(); const router = useRouter(); // The header links to your profile, so highlight it as the active row (same @@ -55,7 +58,7 @@ export const ProfilePanelSection = ({ [ { title: 'Following', - path: '/following', + path: `${webappUrl}following`, action: () => onNavTabClick?.(OtherFeedPage.Following), icon: (active: boolean) => ( } /> @@ -112,9 +115,10 @@ export const ProfilePanelSection = ({ icon: (active: boolean) => ( } /> ), + ...(isSaleActive && { rightIcon: () => }), }, ].filter(Boolean) as SidebarMenuItem[], - [onNavTabClick, isPlus, logSubscriptionEvent], + [onNavTabClick, isPlus, isSaleActive, logSubscriptionEvent], ); if (!user) { diff --git a/packages/shared/src/components/sidebar/sections/ProfileSection.tsx b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx index c1eac84f548..7bc469ed275 100644 --- a/packages/shared/src/components/sidebar/sections/ProfileSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx @@ -16,6 +16,7 @@ import { useAuthContext } from '../../../contexts/AuthContext'; import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; import { useAlertsContext } from '../../../contexts/AlertContext'; import { useLogOpportunityNudgeClick } from '../../../hooks/log/useLogOpportunityNudgeClick'; +import { useJobsFeature } from '../../../hooks/useJobsFeature'; export const ProfileSection = ({ isItemsButton, @@ -25,6 +26,7 @@ export const ProfileSection = ({ const { user } = useAuthContext(); const { alerts } = useAlertsContext(); const logOpportunityNudgeClick = useLogOpportunityNudgeClick(); + const { isJobsEnabled } = useJobsFeature(); const menuItems: SidebarMenuItem[] = useMemo(() => { if (!user?.username) { @@ -46,16 +48,20 @@ export const ProfileSection = ({ } /> ), }, - { - title: 'Jobs', - path: `${webappUrl}jobs${ - alerts.opportunityId ? `/${alerts.opportunityId}` : '' - }`, - action: logOpportunityNudgeClick, - icon: (active: boolean) => ( - } /> - ), - }, + ...(isJobsEnabled + ? [ + { + title: 'Jobs', + path: `${webappUrl}jobs${ + alerts.opportunityId ? `/${alerts.opportunityId}` : '' + }`, + action: logOpportunityNudgeClick, + icon: (active: boolean) => ( + } /> + ), + }, + ] + : []), ...(hasAccessToCores ? [ { @@ -75,7 +81,13 @@ export const ProfileSection = ({ ), }, ]; - }, [alerts.opportunityId, hasAccessToCores, logOpportunityNudgeClick, user]); + }, [ + alerts.opportunityId, + hasAccessToCores, + isJobsEnabled, + logOpportunityNudgeClick, + user, + ]); if (menuItems.length === 0) { return null; diff --git a/packages/shared/src/components/sidebar/sections/RecentSection.tsx b/packages/shared/src/components/sidebar/sections/RecentSection.tsx index 5d2c784a563..20bc0e2ff54 100644 --- a/packages/shared/src/components/sidebar/sections/RecentSection.tsx +++ b/packages/shared/src/components/sidebar/sections/RecentSection.tsx @@ -21,6 +21,7 @@ import { } from '../../icons'; import { Image, ImageType } from '../../image/Image'; import { Section } from '../Section'; +import { webappUrl } from '../../../lib/constants'; import { SidebarSettingsFlags } from '../../../graphql/settings'; import { sourceQueryOptions } from '../../../graphql/sources'; import type { SidebarSectionProps } from './common'; @@ -29,6 +30,7 @@ import { useRecentPages } from '../../../hooks/useRecentPages'; import { useAuthContext } from '../../../contexts/AuthContext'; import { useSquad } from '../../../hooks/squads/useSquad'; import { useUserShortByIdQuery } from '../../../hooks/user/useUserShortByIdQuery'; +import { useJobsFeature } from '../../../hooks/useJobsFeature'; // Older stored entries predate `type`; fall back to the path prefix so they // still get a recognizable icon until they're re-recorded with a type. @@ -51,6 +53,8 @@ const resolveType = (page: RecentPage): RecentPageType => { const firstSegment = (path: string): string => path.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] ?? ''; +const isJobsPath = (path: string): boolean => firstSegment(path) === 'jobs'; + // Recognizable glyphs for known internal destinations, keyed by the leading // path segment, so a recent page reads as itself (Game Center, Settings, // Notifications…) instead of the generic "history" timer. Anything unmapped @@ -143,21 +147,27 @@ export const RecentSection = ({ ...defaultRenderSectionProps }: SidebarSectionProps): ReactElement | null => { const recentPages = useRecentPages(); + const { isJobsEnabled } = useJobsFeature(); const menuItems: SidebarMenuItem[] = useMemo( () => - recentPages.map((page) => ({ - icon: () => , - title: page.title, - path: page.path, - // Recent mirrors pages you've already visited (often the current one), - // so it should never render as the active nav item. - disableActiveState: true, - })), - [recentPages], + recentPages + .filter((page) => isJobsEnabled || !isJobsPath(page.path)) + .map((page) => ({ + icon: () => , + title: page.title, + // Recorded from `router.asPath`, so always relative. The stored value + // stays that way — `resolveType`/`handleFromPath` match on path prefixes + // — and only the rendered link carries the origin. + path: `${webappUrl}${page.path.replace(/^\//, '')}`, + // Recent mirrors pages you've already visited (often the current one), + // so it should never render as the active nav item. + disableActiveState: true, + })), + [isJobsEnabled, recentPages], ); - if (!recentPages.length) { + if (!menuItems.length) { return null; } diff --git a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx index 58f4342274a..20a0c411b21 100644 --- a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx @@ -34,6 +34,7 @@ import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent, TargetId } from '../../../lib/log'; +import { useJobsFeature } from '../../../hooks/useJobsFeature'; const settingsDefaultPath = `${settingsUrl}/profile`; @@ -49,6 +50,7 @@ export const SettingsPanelSection = ({ }: SidebarSectionProps): ReactElement => { const { openModal } = useLazyModal(); const { logEvent } = useLogContext(); + const { isJobsEnabled } = useJobsFeature(); const groups: SettingsGroup[] = useMemo( () => [ @@ -76,18 +78,22 @@ export const SettingsPanelSection = ({ } /> ), }, - { - title: 'Job preferences', - path: `${settingsUrl}/job-preferences`, - icon: (active: boolean) => ( - } /> - ), - action: () => - logEvent({ - event_name: LogEvent.ClickCandidatePreferences, - target_id: TargetId.ProfileSettingsMenu, - }), - }, + ...(isJobsEnabled + ? [ + { + title: 'Job preferences', + path: `${settingsUrl}/job-preferences`, + icon: (active: boolean) => ( + } /> + ), + action: () => + logEvent({ + event_name: LogEvent.ClickCandidatePreferences, + target_id: TargetId.ProfileSettingsMenu, + }), + }, + ] + : []), { title: 'Appearance', path: `${settingsUrl}/appearance`, @@ -270,7 +276,7 @@ export const SettingsPanelSection = ({ ], }, ], - [logEvent, openModal], + [isJobsEnabled, logEvent, openModal], ); return ( diff --git a/packages/shared/src/components/squads/SquadHeaderMenu.spec.tsx b/packages/shared/src/components/squads/SquadHeaderMenu.spec.tsx new file mode 100644 index 00000000000..f883d4cf58b --- /dev/null +++ b/packages/shared/src/components/squads/SquadHeaderMenu.spec.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { generateTestSquad } from '../../../__tests__/fixture/squads'; +import type { MenuItemProps } from '../dropdown/common'; +import type { Boot } from '../../lib/boot'; +import { BOOT_QUERY_KEY } from '../../contexts/common'; +import { SourcePermissions } from '../../graphql/sources'; +import { deleteSquad } from '../../graphql/squads'; +import { PROMPT_KEY } from '../../hooks/usePrompt'; +import { PromptElement } from '../modals/Prompt'; +import SquadHeaderMenu from './SquadHeaderMenu'; + +const mockReplace = jest.fn(); + +jest.mock('next/router', () => ({ + useRouter: () => ({ + pathname: '/', + push: jest.fn(), + replace: mockReplace, + }), +})); + +jest.mock('../../graphql/squads', () => ({ + ...(jest.requireActual('../../graphql/squads') as Record), + deleteSquad: jest.fn(), +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: () => ({ + isLoggedIn: true, + }), +})); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ + logEvent: jest.fn(), + }), +})); + +jest.mock('../../hooks/useLazyModal', () => ({ + useLazyModal: () => ({ + openModal: jest.fn(), + }), +})); + +jest.mock('../../hooks/useSquadInvitation', () => ({ + useSquadInvitation: () => ({ + logAndCopyLink: jest.fn(), + }), +})); + +jest.mock('../../hooks/contentPreference/useContentPreference', () => ({ + useContentPreference: () => ({ + follow: jest.fn(), + unfollow: jest.fn(), + }), +})); + +jest.mock('../../hooks', () => ({ + ...(jest.requireActual('../../hooks') as Record), + useLeaveSquad: () => jest.fn(), + useSquadNavigation: () => ({ + editSquad: jest.fn(), + }), +})); + +jest.mock('../dropdown/DropdownMenu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => + children, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + DropdownMenuOptions: ({ options }: { options: MenuItemProps[] }) => ( +
    + {options.map(({ label, action }) => ( + + ))} +
    + ), +})); + +const mockedDeleteSquad = jest.mocked(deleteSquad); + +describe('SquadHeaderMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps the delete prompt visible while the delete request is pending', async () => { + const squad = generateTestSquad({ + currentMember: { + ...generateTestSquad().currentMember!, + permissions: [SourcePermissions.Delete], + }, + }); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + queryClient.setQueryData(BOOT_QUERY_KEY, { squads: [squad] } as Boot); + mockedDeleteSquad.mockReturnValue(new Promise(() => undefined)); + + render( + + + + , + ); + await waitFor(() => + expect(queryClient.getQueryState(PROMPT_KEY)?.fetchStatus).toBe('idle'), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Delete Squad' })); + await userEvent.click( + await screen.findByRole('button', { name: 'Yes, delete Squad' }), + ); + + const promptButton = screen.getByRole('button', { + name: 'Yes, delete Squad', + }); + expect(promptButton).toHaveAttribute('aria-busy', 'true'); + expect(promptButton).toBeDisabled(); + expect(screen.getByText(`Delete ${squad.name}`)).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/squads/settings/SquadDangerZone.tsx b/packages/shared/src/components/squads/settings/SquadDangerZone.tsx index 16d2b5a4120..0e4e6481e34 100644 --- a/packages/shared/src/components/squads/settings/SquadDangerZone.tsx +++ b/packages/shared/src/components/squads/settings/SquadDangerZone.tsx @@ -29,7 +29,7 @@ const Important = () => ( export function SquadDangerZone({ squad }: SquadDangerZoneProps): ReactElement { const router = useRouter(); - const { onDeleteSquad } = useDeleteSquad({ + const { isPending, onDeleteSquad } = useDeleteSquad({ squad, callback: () => router.replace('/'), }); @@ -47,6 +47,8 @@ export function SquadDangerZone({ squad }: SquadDangerZoneProps): ReactElement { 'Allow your Squad name to become available to anyone.', ]} important={} + buttonDisabled={isPending} + buttonLoading={isPending} /> ); diff --git a/packages/shared/src/components/squads/stack/SourceStackModal.tsx b/packages/shared/src/components/squads/stack/SourceStackModal.tsx index e3955baffd2..8f4fe2f517f 100644 --- a/packages/shared/src/components/squads/stack/SourceStackModal.tsx +++ b/packages/shared/src/components/squads/stack/SourceStackModal.tsx @@ -16,7 +16,7 @@ import type { SourceStack, AddSourceStackInput, } from '../../../graphql/source/sourceStack'; -import type { DatasetTool } from '../../../graphql/user/userStack'; +import type { AutocompleteTool } from '../../../graphql/user/userStack'; import { useStackSearch } from '../../../features/profile/hooks/useStackSearch'; import { PlusIcon } from '../../icons'; @@ -62,7 +62,7 @@ export function SourceStackModal({ const canSubmit = title.trim().length > 0; - const handleSelectSuggestion = (suggestion: DatasetTool) => { + const handleSelectSuggestion = (suggestion: AutocompleteTool) => { setValue('title', suggestion.title); setShowSuggestions(false); }; diff --git a/packages/shared/src/components/streak/offers/StreakOfferCarousel.tsx b/packages/shared/src/components/streak/offers/StreakOfferCarousel.tsx new file mode 100644 index 00000000000..5a9c607affa --- /dev/null +++ b/packages/shared/src/components/streak/offers/StreakOfferCarousel.tsx @@ -0,0 +1,216 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { UserOffer } from '../../../graphql/offers'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + ClaimedChip, + FinePrint, + GiftHeadline, + OfferLogo, + offerBadgeLabels, +} from './common'; +import { StreakOfferCelebrationCompact } from './StreakOfferCelebration'; + +const CARD_STEP = 13; // rem: card width plus gap, used to slide the track. +const SWIPE_THRESHOLD = 48; // px of travel before a swipe counts as a move. + +const CarouselCard = ({ + offer, + isActive, + onSelect, +}: { + offer: UserOffer; + isActive: boolean; + onSelect: () => void; +}): ReactElement => ( + +); + +export const StreakOfferCarousel = ({ + currentStreak, + offers, + claimedUids, + onClaim, + onDecline, + onVisible, +}: { + currentStreak: number; + offers: UserOffer[]; + claimedUids: Set; + onClaim: (offer: UserOffer) => void; + onDecline: () => void; + /** Only the centred card is visible, so delivery is reported per card. */ + onVisible: (offers: UserOffer[]) => void; +}): ReactElement => { + const [index, setIndex] = useState(0); + const [drag, setDrag] = useState(0); + const startX = useRef(null); + // The travelled distance lives in a ref as well as state: state drives the + // visual offset, but a fast flick can end before React re-renders, and the + // release has to know how far the finger actually went. + const travelled = useRef(0); + // On touch, a drag that ends on a card is followed by that card's click — + // without suppression the click re-selects the old card and the swipe + // snaps back. + const suppressClick = useRef(false); + const active = offers[index]; + const isClaimed = active && claimedUids.has(active.impressionUid); + + useEffect(() => { + if (active) { + onVisible([active]); + } + }, [active, onVisible]); + + const onPointerDown = useCallback((event: React.PointerEvent) => { + startX.current = event.clientX; + travelled.current = 0; + suppressClick.current = false; + }, []); + + const onPointerMove = useCallback((event: React.PointerEvent) => { + if (startX.current === null) { + return; + } + + travelled.current = event.clientX - startX.current; + setDrag(travelled.current); + }, []); + + const onPointerUp = useCallback(() => { + if (startX.current === null) { + return; + } + + const distance = travelled.current; + + startX.current = null; + travelled.current = 0; + setDrag(0); + + suppressClick.current = Math.abs(distance) > SWIPE_THRESHOLD; + + if (distance < -SWIPE_THRESHOLD) { + setIndex((current) => Math.min(current + 1, offers.length - 1)); + } else if (distance > SWIPE_THRESHOLD) { + setIndex((current) => Math.max(current - 1, 0)); + } + }, [offers.length]); + + return ( +
    + + + + +
    + {/* The track slides rather than scrolls, so the centred card is always + the one the claim button acts on. */} +
    +
    + {offers.map((offer, cardIndex) => ( + { + if (suppressClick.current) { + suppressClick.current = false; + return; + } + setIndex(cardIndex); + }} + /> + ))} +
    +
    + + {offers.length > 1 && ( +
    + {offers.map((offer, dotIndex) => ( +
    + )} + +
    + + {isClaimed ? ( + + {`${active.advertiserName} gift claimed`} + + ) : ( + + )} + +
    +
    +
    + ); +}; diff --git a/packages/shared/src/components/streak/offers/StreakOfferCelebration.tsx b/packages/shared/src/components/streak/offers/StreakOfferCelebration.tsx new file mode 100644 index 00000000000..2ea5d40b5b7 --- /dev/null +++ b/packages/shared/src/components/streak/offers/StreakOfferCelebration.tsx @@ -0,0 +1,144 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + milestoneForStreak, + streakTierArt, + streakWeekDays, +} from './streakTiers'; + +// The celebration half of the moment, per the milestone-rewards design +// exploration: everything here belongs to daily.dev, no partner paint. +// Gradients and glows are the design's own values; they intentionally bypass +// theme tokens the same way brand paint does (see EngagementAdCta). + +const panelBackground = + 'radial-gradient(120% 100% at 20% 0%, rgba(236,82,122,0.38) 0%, rgba(236,82,122,0.22) 42%, rgba(15,18,24,0) 78%), linear-gradient(160deg, rgba(177,75,215,0.18) 0%, rgba(15,18,24,0) 60%)'; + +const badgeGlow = + 'radial-gradient(circle, rgba(236,82,122,0.55) 0%, rgba(177,75,215,0.25) 45%, transparent 70%)'; + +export const FlameBadge = ({ + tier, + label, + className, +}: { + tier: string; + label: string; + className?: string; +}): ReactElement => ( +
    + + {`${label} +
    +); + +const TierName = ({ label }: { label: string }): ReactElement => ( + + {label} + +); + +const DayStrip = ({ className }: { className?: string }): ReactElement => ( +
    + {streakWeekDays.map((label, index) => ( + + {label} + + ))} +
    +); + +/** The split popup's left panel: flame, tier, count, headline, week strip. */ +export const StreakOfferCelebration = ({ + currentStreak, + className, +}: { + currentStreak: number; + className?: string; +}): ReactElement => { + const milestone = milestoneForStreak(currentStreak); + + return ( +
    + + +
    +
    + + {currentStreak} + + day streak +
    +

    {milestone.headline}

    +
    + +
    + ); +}; + +/** The carousel's compact header: small flame and the count on one line. */ +export const StreakOfferCelebrationCompact = ({ + currentStreak, + children, + className, +}: { + currentStreak: number; + children?: ReactNode; + className?: string; +}): ReactElement => { + const milestone = milestoneForStreak(currentStreak); + + return ( +
    +
    + + + + {currentStreak} + + day streak + +
    + {children} +
    + ); +}; diff --git a/packages/shared/src/components/streak/offers/StreakOfferSplit.tsx b/packages/shared/src/components/streak/offers/StreakOfferSplit.tsx new file mode 100644 index 00000000000..de839236391 --- /dev/null +++ b/packages/shared/src/components/streak/offers/StreakOfferSplit.tsx @@ -0,0 +1,103 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import type { UserOffer } from '../../../graphql/offers'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + ClaimedChip, + FinePrint, + GiftHeadline, + OfferLogo, + offerBadgeLabels, +} from './common'; +import { StreakOfferCelebration } from './StreakOfferCelebration'; + +const OfferListRow = ({ + offer, + isClaimed, + onClaim, +}: { + offer: UserOffer; + isClaimed: boolean; + onClaim: (offer: UserOffer) => void; +}): ReactElement => ( +
    + +
    + {offer.title} + + {[ + offer.advertiserName, + offer.perk ?? + offer.description ?? + (offer.badgeLabel && offerBadgeLabels[offer.badgeLabel]), + ] + .filter(Boolean) + .join(' · ')} + +
    + {isClaimed ? ( + + Claimed + + ) : ( + + )} +
    +); + +export const StreakOfferSplit = ({ + currentStreak, + offers, + claimedUids, + onClaim, + onVisible, +}: { + currentStreak: number; + offers: UserOffer[]; + claimedUids: Set; + onClaim: (offer: UserOffer) => void; + /** All rows render at once, so every offer counts as delivered on mount. */ + onVisible: (offers: UserOffer[]) => void; +}): ReactElement => { + const reportedVisible = useRef(false); + + useEffect(() => { + if (reportedVisible.current) { + return; + } + + reportedVisible.current = true; + onVisible(offers); + }, [offers, onVisible]); + + return ( +
    + + +
    + +
    + {offers.map((offer) => ( + + ))} +
    + +
    +
    + ); +}; diff --git a/packages/shared/src/components/streak/offers/common.tsx b/packages/shared/src/components/streak/offers/common.tsx new file mode 100644 index 00000000000..b47d24534bd --- /dev/null +++ b/packages/shared/src/components/streak/offers/common.tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { UserOffer } from '../../../graphql/offers'; +import { VIcon } from '../../icons'; +import { IconSize } from '../../Icon'; + +export const offerBadgeLabels: Record< + NonNullable, + string +> = { + free_trial: 'Free trial', + discount: 'Discount', +}; + +export const OfferLogo = ({ + offer, + className, +}: { + offer: UserOffer; + className?: string; +}): ReactElement => { + const src = offer.advertiserLogo || offer.imageUrl; + + if (!src) { + return ( + + {offer.advertiserName.charAt(0)} + + ); + } + + return ( + {`${offer.advertiserName} + ); +}; + +export const GiftHeadline = ({ + count, + centered, + className, +}: { + count: number; + centered?: boolean; + className?: string; +}): ReactElement => ( +
    +

    + Here's a little{' '} + gift from us +

    +

    + {count > 1 + ? 'Choose one of our partner offers below' + : 'A partner offer, on your streak'} +

    +
    +); + +export const FinePrint = ({ + className, +}: { + className?: string; +}): ReactElement => ( +

    + Sponsored offers. No charge until a trial ends, cancel anytime. +

    +); + +export const ClaimedChip = ({ + children, + className, +}: { + children: string; + className?: string; +}): ReactElement => ( + + + {children} + +); diff --git a/packages/shared/src/components/streak/offers/streakTiers.ts b/packages/shared/src/components/streak/offers/streakTiers.ts new file mode 100644 index 00000000000..fc02197cb91 --- /dev/null +++ b/packages/shared/src/components/streak/offers/streakTiers.ts @@ -0,0 +1,64 @@ +// Streak tier ladder from the milestone-rewards design exploration (#6486), +// itself modeled on the streak progression system (#5613). Until that system +// ships, the popup derives the tier from the highest ladder step at or below +// the current streak. + +export type StreakTierMilestone = { + day: number; + tier: string; + label: string; + headline: string; +}; + +const streakTierLadder: StreakTierMilestone[] = [ + { day: 3, tier: 'spark', label: 'Spark', headline: 'Three days in a row' }, + { day: 5, tier: 'kindle', label: 'Kindle', headline: 'Five days in a row' }, + { day: 7, tier: 'flame', label: 'Flame', headline: 'A full week, unbroken' }, + { day: 14, tier: 'blaze', label: 'Blaze', headline: 'Two weeks straight' }, + { + day: 21, + tier: 'firestorm', + label: 'Firestorm', + headline: 'Twenty one days', + }, + { + day: 30, + tier: 'inferno', + label: 'Inferno', + headline: 'A full month, unbroken', + }, + { day: 60, tier: 'scorcher', label: 'Scorcher', headline: 'Sixty days' }, + { + day: 90, + tier: 'eternal-flame', + label: 'Eternal Flame', + headline: 'Ninety days', + }, + { day: 180, tier: 'supernova', label: 'Supernova', headline: 'Half a year' }, + { + day: 365, + tier: 'legendary', + label: 'Legendary', + headline: 'One year, every single day', + }, +]; + +export const milestoneForStreak = (day: number): StreakTierMilestone => { + const reached = streakTierLadder.filter((milestone) => milestone.day <= day); + const tier = reached[reached.length - 1] ?? streakTierLadder[0]; + + // The ladder's headlines are written for their exact day ("A full week, + // unbroken" is only true at 7), but the milestone alert fires on the API's + // own schedule (Fibonacci days like 2, 8, 13) — any other day keeps the + // tier art/label (tiers are ranges) with copy derived from the count. + if (tier.day === day) { + return tier; + } + + return { ...tier, headline: `${day} days in a row` }; +}; + +export const streakTierArt = (tier: string): string => + `https://media.daily.dev/image/upload/f_auto,q_auto/public/streak-tier-${tier}`; + +export const streakWeekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; diff --git a/packages/shared/src/components/tags/TagTopicPage.tsx b/packages/shared/src/components/tags/TagTopicPage.tsx index 7381cf8f703..aefa644bbbc 100644 --- a/packages/shared/src/components/tags/TagTopicPage.tsx +++ b/packages/shared/src/components/tags/TagTopicPage.tsx @@ -1,7 +1,7 @@ import type { ReactElement, ReactNode } from 'react'; import React, { useContext, useMemo } from 'react'; import { useRouter } from 'next/router'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import Head from 'next/head'; import Feed from '../Feed'; import { @@ -15,7 +15,12 @@ import type { ButtonProps } from '../buttons/Button'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import useTagAndSource from '../../hooks/useTagAndSource'; import { AuthTriggers } from '../../lib/auth'; -import { OtherFeedPage, RequestKey, StaleTime } from '../../lib/query'; +import { + generateQueryKey, + OtherFeedPage, + RequestKey, + StaleTime, +} from '../../lib/query'; import { LogEvent, Origin } from '../../lib/log'; import type { Keyword } from '../../graphql/keywords'; import { IconSize } from '../Icon'; @@ -47,7 +52,12 @@ import CustomFeedOptionsMenu from '../CustomFeedOptionsMenu'; import { ArchiveEntryCard } from '../archive/ArchiveEntryCard'; import { ArchiveScopeType } from '../../graphql/archive'; import { useContentPreference } from '../../hooks/contentPreference/useContentPreference'; -import { ContentPreferenceType } from '../../graphql/contentPreference'; +import { useContentPreferenceStatusQuery } from '../../hooks/contentPreference/useContentPreferenceStatusQuery'; +import { + ContentPreferenceStatus, + ContentPreferenceType, +} from '../../graphql/contentPreference'; +import SourceActionsNotify from '../sources/SourceActions/SourceActionsNotify'; import { TOP_CREATORS_BY_TAG_QUERY } from '../../graphql/users'; import type { UserShortProfile } from '../../lib/user'; import { SponsoredTagHero } from '../brand/SponsoredTagHero'; @@ -222,6 +232,7 @@ export const TagTopicPage = ({ jsonLd, }: TagTopicPageProps): ReactElement => { const { push } = useRouter(); + const queryClient = useQueryClient(); const showRoadmap = useFeature(feature.showRoadmap); const { user, showLogin } = useContext(AuthContext); const { feedSettings } = useFeedSettings(); @@ -235,7 +246,7 @@ export const TagTopicPage = ({ ); const { onFollowTags, onUnfollowTags, onBlockTags, onUnblockTags } = useTagAndSource({ origin: Origin.TagPage }); - const { follow, unfollow } = useContentPreference({ + const { follow, unfollow, subscribe, unsubscribe } = useContentPreference({ showToastOnSuccess: false, }); @@ -273,6 +284,21 @@ export const TagTopicPage = ({ return 'unfollowed'; }, [feedSettings, tag]); + // Follow state for tags lives in feed settings (`includeTags`), which can't + // tell "following" apart from "subscribed" — read the keyword's content + // preference so the notify bell knows which state it's in. Only followed + // tags render the bell, so don't spend a request on every other visitor. + const tagPreferenceQueryKey = generateQueryKey( + RequestKey.ContentPreference, + user, + { id: tag, entity: ContentPreferenceType.Keyword }, + ); + const { data: tagPreference } = useContentPreferenceStatusQuery({ + id: tag, + entity: ContentPreferenceType.Keyword, + queryOptions: { enabled: tagStatus === 'followed' }, + }); + const followButtonProps: ButtonProps<'button'> = { size: ButtonSize.Small, icon: tagStatus === 'followed' ? : , @@ -286,6 +312,12 @@ export const TagTopicPage = ({ } else { await onFollowTags({ tags: [tag] }); } + // Following here goes through feed settings, which never touches the + // keyword's content-preference status key. Drop the cached entry rather + // than invalidating it: the query is disabled the moment the tag is + // unfollowed, so an invalidated-but-present entry would just be replayed + // on re-follow and render a stale `subscribed` bell. + queryClient.removeQueries({ queryKey: tagPreferenceQueryKey }); }, }; @@ -305,6 +337,26 @@ export const TagTopicPage = ({ }, }; + const isSubscribedToTag = + tagPreference?.status === ContentPreferenceStatus.Subscribed; + + const { mutate: onNotifyClick, isPending: isNotifyPending } = useMutation({ + mutationFn: async (): Promise => { + const params = { + id: tag, + entity: ContentPreferenceType.Keyword, + entityName: title, + opts: { extra: { origin: Origin.TagPage } }, + }; + + if (isSubscribedToTag) { + await unsubscribe(params); + } else { + await subscribe(params); + } + }, + }); + const statParts: ReactNode[] = []; if (typeof followers === 'number') { statParts.push( @@ -380,6 +432,13 @@ export const TagTopicPage = ({ {tagStatus === 'followed' ? 'Following' : 'Follow'} )} + {tagStatus === 'followed' && ( + onNotifyClick()} + disabled={isNotifyPending} + /> + )} {tagStatus !== 'followed' && ( ); diff --git a/packages/shared/src/features/daily/CoverClosing.tsx b/packages/shared/src/features/daily/CoverClosing.tsx deleted file mode 100644 index ad5528bd52b..00000000000 --- a/packages/shared/src/features/daily/CoverClosing.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; -import { subHours } from 'date-fns'; -import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; -import formatInTimeZone from 'date-fns-tz/formatInTimeZone'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../components/typography/Typography'; -import { HomeIcon, VIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { Button, ButtonVariant } from '../../components/buttons/Button'; -import { useLogContext } from '../../contexts/LogContext'; -import { LogEvent, Origin } from '../../lib/log'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { useDailyPage } from '../../hooks/feed/useDailyPage'; -import usePersistentContext from '../../hooks/usePersistentContext'; -import type { Vote } from './DailyFeedback'; -import { DailyFeedback } from './DailyFeedback'; - -type StoredFeedback = { date: string; vote: Vote }; - -const DAILY_DROP_HOUR = 9; - -const todayKey = (timeZone: string): string => - formatInTimeZone( - subHours(new Date(), DAILY_DROP_HOUR), - timeZone, - 'yyyy-MM-dd', - ); - -const formatNextDrop = (timeZone: string): string => { - const base = utcToZonedTime(new Date(), timeZone); - base.setDate(base.getDate() + 1); - base.setHours(DAILY_DROP_HOUR, 0, 0, 0); - return zonedTimeToUtc(base, timeZone).toLocaleString(undefined, { - weekday: 'long', - hour: 'numeric', - minute: '2-digit', - timeZone, - }); -}; - -interface CoverClosingProps { - onBackToFeed?: () => void; -} - -export const CoverClosing = ({ - onBackToFeed, -}: CoverClosingProps): ReactElement => { - const { user } = useAuthContext(); - const { isDailyDefault } = useDailyPage(); - const timezone = - user?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; - const tomorrow = useMemo(() => formatNextDrop(timezone), [timezone]); - const { logEvent } = useLogContext(); - const [storedFeedback, setStoredFeedback] = - usePersistentContext('daily_feedback', null); - const todaysVote = - storedFeedback?.date === todayKey(timezone) ? storedFeedback.vote : null; - - return ( -
    - { - setStoredFeedback({ date: todayKey(timezone), vote }); - logEvent({ - event_name: LogEvent.DailyFeedback, - extra: JSON.stringify({ origin: Origin.DailyPage, vote }), - }); - }} - /> - -
    -
    - -
    - - You're all caught up - - - Next Daily drops {tomorrow}. - -
    - - {isDailyDefault && ( - - )} -
    - ); -}; diff --git a/packages/shared/src/features/daily/CoverGrid.tsx b/packages/shared/src/features/daily/CoverGrid.tsx deleted file mode 100644 index 2e96d89ce49..00000000000 --- a/packages/shared/src/features/daily/CoverGrid.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useMemo, useState } from 'react'; -import classNames from 'classnames'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../components/typography/Typography'; -import { - StarIcon, - UpvoteIcon, - DiscussIcon, - OpenLinkIcon, - ArrowIcon, -} from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { useAdQuery } from '../monetization/useAdQuery'; -import { AdActions, AdPlacement } from '../../lib/ads'; -import { AdPixel } from '../../components/cards/ad/common/AdPixel'; -import { AdViewability } from '../../components/cards/ad/common/AdViewability'; -import { viewabilityLogExtra } from '../monetization/viewability'; -import { getAdFaviconImageLink } from '../../components/cards/ad/common/getAdFaviconImageLink'; -import { useScrambler } from '../../hooks/useScrambler'; -import { adFaviconPlaceholder } from '../../lib/image'; -import { useFeature } from '../../components/GrowthBookProvider'; -import { adImprovementsV3Feature } from '../../lib/featureManagement'; -import { combinedClicks } from '../../lib/click'; -import { usePlusSubscription } from '../../hooks/usePlusSubscription'; -import { useLogContext } from '../../contexts/LogContext'; -import { LogEvent, Origin } from '../../lib/log'; -import { adLogEvent, feedLogExtra, postLogEvent } from '../../lib/feed'; -import Link from '../../components/utilities/Link'; -import type { Ad, Post } from '../../graphql/posts'; -import { ElementPlaceholder } from '../../components/ElementPlaceholder'; -import useLogImpression from '../../hooks/feed/useLogImpression'; -import { FeedItemType } from '../../components/cards/common/common'; -import { useSmartTitle } from '../../hooks/post/useSmartTitle'; -import { useBookmarkPost } from '../../hooks/useBookmarkPost'; -import type { UseVotePostProps } from '../../hooks/vote/types'; -import { BookmarkButton } from '../../components/buttons/BookmarkButton'; -import { ButtonSize } from '../../components/buttons/Button'; -import { useDailyFeed } from './hooks/useDailyFeed'; -import { DailyPostVotes } from './DailyPostVotes'; -import { - createBookmarkOnMutate, - createVoteOnMutate, -} from './optimisticMutations'; - -const AD_SLOT_INDEX = 1; -const DAILY_FEED_NAME = 'daily'; -const LIST_CLASS = - '-mx-4 divide-y divide-border-subtlest-quaternary overflow-hidden bg-background-default tablet:mx-0 tablet:rounded-12 tablet:border tablet:border-border-subtlest-quaternary'; - -// Picks is a single-column list, so grid position is always column 0 of 1. -const dailyFeedExtra = () => - feedLogExtra(DAILY_FEED_NAME, undefined, undefined, Origin.DailyPage); - -const InlineStat = ({ - icon, - value, - ariaLabel, -}: { - icon: ReactElement; - value: number; - ariaLabel: string; -}): ReactElement => ( - - {icon} - - {value} - - -); - -const AdRow = ({ ad }: { ad: Ad }): ReactElement => { - const { logEvent } = useLogContext(); - const adImprovementsV3 = useFeature(adImprovementsV3Feature); - const faviconSrc = getAdFaviconImageLink({ ad, adImprovementsV3, size: 24 }); - const adLabel = useScrambler('Ad'); - const impressionRef = useLogImpression( - { - type: FeedItemType.Ad, - ad, - index: AD_SLOT_INDEX, - updatedAt: 0, - dataUpdatedAt: 0, - }, - AD_SLOT_INDEX, - 1, - 0, - AD_SLOT_INDEX, - DAILY_FEED_NAME, - undefined, - undefined, - Origin.DailyPage, - ); - - return ( -
  • - - logEvent( - adLogEvent(LogEvent.Click, ad, { - columns: 1, - column: 0, - row: AD_SLOT_INDEX, - ...dailyFeedExtra(), - }), - ), - )} - className="group flex w-full items-center gap-4 px-4 py-4 text-left transition-colors hover:bg-surface-float tablet:px-5" - > - - {ad.description} - - - - {adLabel} - - - - - - - - - - logEvent( - adLogEvent(AdActions.Viewable, ad, { - columns: 1, - column: 0, - row: AD_SLOT_INDEX, - extra: { - ...dailyFeedExtra().extra, - ...viewabilityLogExtra(data), - }, - }), - ) - } - /> -
  • - ); -}; - -const PickRow = ({ - post, - position, - onExpand, - onBookmark, - onVoteMutate, -}: { - post: Post; - position: number; - onExpand: () => void; - onBookmark: () => void; - onVoteMutate: UseVotePostProps['onMutate']; -}): ReactElement => { - const { source } = post; - const { title } = useSmartTitle(post); - const summary = post.summary || post.sharedPost?.summary; - const [isExpanded, setIsExpanded] = useState(false); - const panelId = `daily-pick-${post.id}`; - const impressionRef = useLogImpression( - { - type: FeedItemType.Post, - post, - page: 0, - index: position, - dataUpdatedAt: 0, - }, - position, - 1, - 0, - position, - DAILY_FEED_NAME, - undefined, - undefined, - Origin.DailyPage, - ); - - const onToggle = () => { - const willOpen = !isExpanded; - setIsExpanded(willOpen); - if (willOpen) { - onExpand(); - } - }; - - return ( -
  • - - {isExpanded ? ( -
    - {summary ? ( - - {summary} - - ) : null} -
    - - - Read more - - -
    - - -
    -
    -
    - ) : null} -
  • - ); -}; - -const PICKS_PLACEHOLDER_COUNT = 5; - -const PickRowSkeleton = (): ReactElement => ( -
  • - - -
  • -); - -export const CoverGrid = (): ReactElement => { - const { logEvent } = useLogContext(); - const { isPlus } = usePlusSubscription(); - const { posts, isPending, updatePost } = useDailyFeed(); - const onVoteMutate = useMemo( - () => createVoteOnMutate(updatePost), - [updatePost], - ); - const onBookmarkMutate = useMemo( - () => createBookmarkOnMutate(updatePost), - [updatePost], - ); - const { toggleBookmark } = useBookmarkPost({ onMutate: onBookmarkMutate }); - const { data: ad } = useAdQuery({ - queryKey: ['ad', 'daily-picks'], - placement: AdPlacement.Feed, - enabled: !isPlus, - }); - - const onPickClick = (post: Post, position: number): void => { - logEvent( - postLogEvent(LogEvent.Click, post, { - columns: 1, - column: 0, - row: position, - ...dailyFeedExtra(), - }), - ); - }; - - const onPickBookmark = (post: Post, position: number): void => { - toggleBookmark({ - post, - origin: Origin.DailyPage, - opts: { - columns: 1, - column: 0, - row: position, - ...dailyFeedExtra(), - }, - }); - }; - - return ( -
    -
    - - - Picks - -
    - {!isPending && !posts.length ? ( -
      -
    1. -
      - -
      -
      - - No picks today - - - We couldn't find posts worth your time today. Come back - tomorrow! - -
      -
    2. - {ad ? : null} -
    - ) : ( -
      - {isPending && !posts.length ? ( - Array.from({ length: PICKS_PLACEHOLDER_COUNT }, (_, i) => i).map( - (i) => , - ) - ) : ( - <> - {posts.map((post, idx) => ( - - {idx === AD_SLOT_INDEX && ad ? : null} - onPickClick(post, idx)} - onBookmark={() => onPickBookmark(post, idx)} - onVoteMutate={onVoteMutate} - /> - - ))} - {/* inject ad when not enough posts for ad slot */} - {!!ad && posts.length > 0 && posts.length <= AD_SLOT_INDEX ? ( - - ) : null} - - )} -
    - )} -
    - ); -}; diff --git a/packages/shared/src/features/daily/CoverHeader.tsx b/packages/shared/src/features/daily/CoverHeader.tsx deleted file mode 100644 index 54b145daf48..00000000000 --- a/packages/shared/src/features/daily/CoverHeader.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../components/typography/Typography'; -import { useAuthContext } from '../../contexts/AuthContext'; - -interface DateParts { - month: string; - day: string; - weekday: string; -} - -const formatDateParts = (timeZone?: string): DateParts => { - const now = new Date(); - return { - month: now - .toLocaleDateString(undefined, { month: 'short', timeZone }) - .toUpperCase(), - day: now.toLocaleDateString(undefined, { day: 'numeric', timeZone }), - weekday: now.toLocaleDateString(undefined, { weekday: 'short', timeZone }), - }; -}; - -const DateWidget = (): ReactElement => { - const { user } = useAuthContext(); - const { month, day, weekday } = formatDateParts(user?.timezone || undefined); - - return ( -
    -
    - - {month} - -
    -
    - - {day} - - - {weekday} - -
    -
    - ); -}; - -export const CoverHeader = (): ReactElement => ( -
    - - Your Daily - - -
    -); diff --git a/packages/shared/src/features/daily/CoverTopics.tsx b/packages/shared/src/features/daily/CoverTopics.tsx deleted file mode 100644 index 58ada7f417a..00000000000 --- a/packages/shared/src/features/daily/CoverTopics.tsx +++ /dev/null @@ -1,503 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useCallback, useMemo, useState } from 'react'; -import classNames from 'classnames'; -import { useQuery } from '@tanstack/react-query'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../components/typography/Typography'; -import { - ArrowIcon, - BriefGradientIcon, - MegaphoneIcon, - SettingsIcon, -} from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../components/buttons/Button'; -import { ElementPlaceholder } from '../../components/ElementPlaceholder'; -import { BookmarkButton } from '../../components/buttons/BookmarkButton'; -import Link from '../../components/utilities/Link'; -import { useBookmarkPost } from '../../hooks/useBookmarkPost'; -import type { UseVotePostProps } from '../../hooks/vote/types'; -import { useUpdateQuery } from '../../hooks/useUpdateQuery'; -import { useLogContext } from '../../contexts/LogContext'; -import { LogEvent, Origin } from '../../lib/log'; -import { feedLogExtra, postLogEvent } from '../../lib/feed'; -import useLogImpression from '../../hooks/feed/useLogImpression'; -import { FeedItemType } from '../../components/cards/common/common'; -import type { Post } from '../../graphql/posts'; -import { PostType } from '../../graphql/posts'; -import { - channelConfigurationsQueryOptions, - dailyHeadlinesQueryOptions, -} from '../../graphql/highlights'; -import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; -import { HeadlinesSettingsModal } from './HeadlinesSettingsModal'; -import { DailyPostVotes } from './DailyPostVotes'; -import type { UpdateDailyPost } from './optimisticMutations'; -import { - createBookmarkOnMutate, - createVoteOnMutate, -} from './optimisticMutations'; - -// channelConfigurations.color holds the full Tailwind text class per channel. -const DEFAULT_COLOR_CLASS = 'text-text-tertiary'; -const DAILY_FEED_NAME = 'daily'; - -// Headlines is a single-column list, so grid position is always column 0 of 1. -const dailyFeedExtra = () => - feedLogExtra(DAILY_FEED_NAME, undefined, undefined, Origin.DailyPage); - -const HeadlineRow = ({ - highlight, - channelName, - colorClass, - position, - onExpand, - onBookmark, - onVoteMutate, -}: { - highlight: Post; - channelName: string; - colorClass: string; - position: number; - onExpand: () => void; - onBookmark: () => void; - onVoteMutate: UseVotePostProps['onMutate']; -}): ReactElement => { - const [isExpanded, setIsExpanded] = useState(false); - const summary = highlight.summary || highlight.sharedPost?.summary; - const panelId = `daily-headline-${highlight.id}`; - const impressionRef = useLogImpression( - { - type: FeedItemType.Post, - post: highlight, - page: 0, - index: position, - dataUpdatedAt: 0, - }, - position, - 1, - 0, - position, - DAILY_FEED_NAME, - undefined, - undefined, - Origin.DailyPage, - ); - - const onToggle = () => { - const willOpen = !isExpanded; - setIsExpanded(willOpen); - if (willOpen) { - onExpand(); - } - }; - - return ( -
  • - - {isExpanded ? ( -
    - {summary ? ( - - {summary} - - ) : null} -
    - - - Read more - - -
    - - -
    -
    -
    - ) : null} -
  • - ); -}; - -// The latest brief rides in `dailyHeadlines` as a `PostType.Brief` edge (Plus + -// subscribed, gated server-side). It renders as the lead row of the list with -// brief branding instead of a channel label, and no vote/bookmark actions. -const BriefHeadlineRow = ({ - brief, - position, - onExpand, -}: { - brief: Post; - position: number; - onExpand: () => void; -}): ReactElement => { - const [isExpanded, setIsExpanded] = useState(false); - const summary = - brief.summary || - brief.sharedPost?.summary || - 'A daily briefing of what matters, generated just for you based on your preferences and interests.'; - const panelId = `daily-brief-${brief.id}`; - const impressionRef = useLogImpression( - { - type: FeedItemType.Post, - post: brief, - page: 0, - index: position, - dataUpdatedAt: 0, - }, - position, - 1, - 0, - position, - DAILY_FEED_NAME, - undefined, - undefined, - Origin.DailyPage, - ); - - const onToggle = () => { - const willOpen = !isExpanded; - setIsExpanded(willOpen); - if (willOpen) { - onExpand(); - } - }; - - return ( -
  • - - {isExpanded ? ( -
    - {summary ? ( - - {summary} - - ) : null} - - - Read briefing - - -
    - ) : null} -
  • - ); -}; - -const HEADLINES_PLACEHOLDER_COUNT = 5; - -const HeadlineRowSkeleton = (): ReactElement => ( -
  • -
    - - -
    -
  • -); - -export const CoverTopics = (): ReactElement => { - const { logEvent } = useLogContext(); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const { isOnboardingComplete } = useOnboardingActions(); - const { data, isPending } = useQuery({ - ...dailyHeadlinesQueryOptions(), - enabled: isOnboardingComplete, - }); - const { data: channelData } = useQuery(channelConfigurationsQueryOptions()); - const [getHeadlines, setHeadlines] = useUpdateQuery( - dailyHeadlinesQueryOptions(), - ); - - const updatePost = useCallback( - (postId, manipulate) => { - const current = getHeadlines(); - - if (!current) { - return; - } - - current.dailyHeadlines.edges.forEach((edge) => { - if (edge.node.id === postId) { - Object.assign(edge.node, manipulate(edge.node)); - } - }); - - setHeadlines(current); - }, - [getHeadlines, setHeadlines], - ); - - const onVoteMutate = useMemo( - () => createVoteOnMutate(updatePost), - [updatePost], - ); - const onBookmarkMutate = useMemo( - () => createBookmarkOnMutate(updatePost), - [updatePost], - ); - const { toggleBookmark } = useBookmarkPost({ onMutate: onBookmarkMutate }); - - const channelBySourceId = useMemo( - () => - new Map( - (channelData?.channelConfigurations ?? []).flatMap((channel) => { - const sourceId = channel.digest?.source?.id; - return sourceId ? [[sourceId, channel] as const] : []; - }), - ), - [channelData], - ); - - const highlights = useMemo( - () => data?.dailyHeadlines.edges.map((edge) => edge.node) ?? [], - [data], - ); - - const onHeadlineClick = (post: Post, position: number) => { - logEvent( - postLogEvent(LogEvent.Click, post, { - columns: 1, - column: 0, - row: position, - ...dailyFeedExtra(), - }), - ); - }; - - const onHeadlineBookmark = (post: Post, position: number) => { - toggleBookmark({ - post, - origin: Origin.DailyPage, - opts: { - columns: 1, - column: 0, - row: position, - ...dailyFeedExtra(), - }, - }); - }; - - return ( -
    -
    - - - Headlines - -
    - {isPending && ( -
      - {Array.from({ length: HEADLINES_PLACEHOLDER_COUNT }, (_, i) => i).map( - (i) => ( - - ), - )} -
    - )} - {!isPending && highlights.length === 0 && ( -
    -
    - -
    -
    - - No headlines, yet... - - - Follow more topic channels and your daily headlines will show up - here. - -
    - -
    - )} - {highlights.length > 0 && ( -
      - {highlights.map((highlight, index) => { - if (highlight.type === PostType.Brief) { - return ( - onHeadlineClick(highlight, index)} - /> - ); - } - - const sourceId = highlight.source?.id; - const config = sourceId - ? channelBySourceId.get(sourceId) - : undefined; - return ( - onHeadlineClick(highlight, index)} - onBookmark={() => onHeadlineBookmark(highlight, index)} - onVoteMutate={onVoteMutate} - /> - ); - })} -
    - )} - {isSettingsOpen ? ( - setIsSettingsOpen(false)} - /> - ) : null} -
    - ); -}; diff --git a/packages/shared/src/features/daily/DailyFeedback.tsx b/packages/shared/src/features/daily/DailyFeedback.tsx deleted file mode 100644 index 83899652454..00000000000 --- a/packages/shared/src/features/daily/DailyFeedback.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../../components/typography/Typography'; -import { UpvoteIcon, DownvoteIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../components/buttons/Button'; - -export type Vote = 'up' | 'down'; - -export interface DailyFeedbackProps { - prompt: string; - upLabel?: string; - downLabel?: string; - thanksLabel?: string; - size?: 'sm' | 'md'; - align?: 'start' | 'center'; - className?: string; - vote?: Vote | null; - onVote?: (vote: Vote) => void; -} - -export const DailyFeedback = ({ - prompt, - upLabel = 'Yes', - downLabel = 'No', - thanksLabel = 'Thanks, noted', - size = 'sm', - align = 'start', - className, - vote = null, - onVote, -}: DailyFeedbackProps): ReactElement => { - const handleVote = (next: Vote) => (e: React.MouseEvent) => { - e.stopPropagation(); - onVote?.(next); - }; - - const iconSize = size === 'md' ? IconSize.Small : IconSize.XSmall; - const labelType = - size === 'md' ? TypographyType.Footnote : TypographyType.Caption1; - - if (vote) { - return ( -
    - {vote === 'up' ? ( - - ) : ( - - )} - - {thanksLabel} - -
    - ); - } - - const buttonSize = size === 'md' ? ButtonSize.Small : ButtonSize.XSmall; - - return ( -
    - - {prompt} - - - -
    - ); -}; diff --git a/packages/shared/src/features/daily/DailyHome.tsx b/packages/shared/src/features/daily/DailyHome.tsx deleted file mode 100644 index 798a4e40ef8..00000000000 --- a/packages/shared/src/features/daily/DailyHome.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useEffect, useMemo } from 'react'; -import classNames from 'classnames'; -import { useMutation } from '@tanstack/react-query'; -import { CoverHeader } from './CoverHeader'; -import { CoverGrid } from './CoverGrid'; -import { CoverTopics } from './CoverTopics'; -import { CoverClosing } from './CoverClosing'; -import { useViewSize, ViewSize } from '../../hooks'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; -import { useFeeds } from '../../hooks/feed/useFeeds'; -import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; -import { markDailySeenMutationOptions } from '../../graphql/highlights'; -import { ExploreChipsBar } from '../../components/feeds/ExploreChipsBar'; -import { buildPersonalizedCategories } from '../../components/feeds/exploreCategories'; -import { pageHeaderClassName } from '../../components/layout/PageHeader'; - -interface DailyHomeProps { - className?: string; - onBackToFeed?: () => void; -} - -export const DailyHome = ({ - className, - onBackToFeed, -}: DailyHomeProps): ReactElement | null => { - const isLaptop = useViewSize(ViewSize.Laptop); - const { isV2 } = useLayoutVariant(); - const { feeds } = useFeeds(); - const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); - const { daily } = useAuthContext(); - const { mutate: markDailySeen } = useMutation(markDailySeenMutationOptions()); - - useEffect(() => { - if (daily) { - markDailySeen(); - } - }, [daily, markDailySeen]); - - const exploreCategories = useMemo( - () => - buildPersonalizedCategories(feeds?.edges ?? [], { - defaultFeedId, - isCustomDefaultFeed, - }), - [feeds?.edges, defaultFeedId, isCustomDefaultFeed], - ); - - return ( - <> - {isLaptop && - (isV2 ? ( -
    - -
    - ) : ( -
    - -
    - ))} -
    -
    - - - - -
    -
    - - ); -}; diff --git a/packages/shared/src/features/daily/DailyPostVotes.tsx b/packages/shared/src/features/daily/DailyPostVotes.tsx deleted file mode 100644 index df48ab0a228..00000000000 --- a/packages/shared/src/features/daily/DailyPostVotes.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { UpvoteIcon, DownvoteIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { CardAction } from '../../components/buttons/CardAction'; -import { ButtonColor } from '../../components/buttons/Button'; -import { Tooltip } from '../../components/tooltip/Tooltip'; -import { useVotePost } from '../../hooks/vote/useVotePost'; -import type { UseVotePostProps } from '../../hooks/vote/types'; -import type { Post } from '../../graphql/posts'; -import { UserVote } from '../../graphql/posts'; -import type { Origin } from '../../lib/log'; -import type { PostLogEventFnOptions } from '../../lib/feed'; - -interface DailyPostVotesProps { - post: Post; - origin: Origin; - opts?: PostLogEventFnOptions; - onMutate?: UseVotePostProps['onMutate']; -} - -export const DailyPostVotes = ({ - post, - origin, - opts, - onMutate, -}: DailyPostVotesProps): ReactElement => { - const { toggleUpvote, toggleDownvote } = useVotePost({ onMutate }); - const vote = post.userState?.vote ?? UserVote.None; - const isUpvoteActive = vote === UserVote.Up; - const isDownvoteActive = vote === UserVote.Down; - - return ( -
    - - toggleUpvote({ payload: post, origin, opts })} - icon={} - iconPressed={} - label={isUpvoteActive ? 'Remove upvote' : 'Upvote'} - /> - - - toggleDownvote({ payload: post, origin, opts })} - icon={} - iconPressed={} - label={isDownvoteActive ? 'Remove downvote' : 'Downvote'} - /> - -
    - ); -}; diff --git a/packages/shared/src/features/daily/DailySwitcher.tsx b/packages/shared/src/features/daily/DailySwitcher.tsx deleted file mode 100644 index d424eb7e010..00000000000 --- a/packages/shared/src/features/daily/DailySwitcher.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { useRouter } from 'next/router'; -import classNames from 'classnames'; -import Link from '../../components/utilities/Link'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../components/typography/Typography'; -import { MagicIcon, HomeIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { webappUrl } from '../../lib/constants'; -import { isExtension } from '../../lib/func'; -import { useDailyPage } from '../../hooks/feed/useDailyPage'; - -interface DailySwitcherProps { - className?: string; - compact?: boolean; - reverse?: boolean; - onFeedClick?: () => void; -} - -const TAB_DAILY = `${webappUrl}daily`; - -export const DailySwitcher = ({ - className, - compact = false, - reverse = false, - onFeedClick, -}: DailySwitcherProps): ReactElement => { - const router = useRouter(); - const { isDailyDefault, isDailyAsDefault, setShowDaily } = useDailyPage(); - const path = router?.pathname ?? ''; - const isDaily = path === TAB_DAILY || isDailyAsDefault; - - const handleDaily = (event: React.MouseEvent) => { - if (isDailyDefault && isExtension) { - event.preventDefault(); - setShowDaily(true); - } - }; - - const handleFeed = (event: React.MouseEvent) => { - if (isDailyDefault) { - if (isExtension) { - event.preventDefault(); - } - setShowDaily(false); - return; - } - - if (onFeedClick) { - event.preventDefault(); - onFeedClick(); - } - }; - - const tabClass = compact - ? 'inline-flex items-center gap-1.5 rounded-8 px-2 py-1 transition-colors' - : 'inline-flex items-center gap-2 rounded-10 px-3 py-1.5 transition-colors'; - - const dailyTab = ( - - - - - Daily - - - - ); - - const feedTab = ( - - - - - Your feed - - - - ); - - return ( - - ); -}; diff --git a/packages/shared/src/features/daily/HeadlinesSettingsModal.tsx b/packages/shared/src/features/daily/HeadlinesSettingsModal.tsx deleted file mode 100644 index d41ce1bab1c..00000000000 --- a/packages/shared/src/features/daily/HeadlinesSettingsModal.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import type ReactModal from 'react-modal'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Modal } from '../../components/modals/common/Modal'; -import { ModalKind, ModalSize } from '../../components/modals/common/types'; -import { ModalHeader } from '../../components/modals/common/ModalHeader'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../../components/typography/Typography'; -import { Switch } from '../../components/fields/Switch'; -import { Loader } from '../../components/Loader'; -import { Pill, PillSize } from '../../components/Pill'; -import { capitalize } from '../../lib/strings'; -import type { ChannelConfiguration } from '../../graphql/highlights'; -import { - channelConfigurationsQueryOptions, - DAILY_HEADLINES_QUERY_KEY, -} from '../../graphql/highlights'; -import type { Source } from '../../graphql/sources'; -import { SourceType } from '../../graphql/sources'; -import { UserPersonalizedDigestType } from '../../graphql/users'; -import { useSourceActionsFollow } from '../../hooks/source/useSourceActionsFollow'; -import { - SendType, - usePersonalizedDigest, -} from '../../hooks/usePersonalizedDigest'; -import { usePlusSubscription } from '../../hooks/usePlusSubscription'; -import { BriefPlusUpgradeCTA } from '../briefing/components/BriefPlusUpgradeCTA'; - -interface HeadlinesSettingsModalProps - extends Omit { - onRequestClose: () => void; -} - -const ChannelRow = ({ - channel, - source, -}: { - channel: ChannelConfiguration; - source: Source; -}): ReactElement => { - const queryClient = useQueryClient(); - const { isFollowing, toggleFollow } = useSourceActionsFollow({ source }); - const inputId = `headline-toggle-${channel.channel}`; - const frequency = channel.digest?.frequency ?? 'daily'; - - const onToggle = async () => { - await toggleFollow(); - - await queryClient.invalidateQueries({ - queryKey: DAILY_HEADLINES_QUERY_KEY, - }); - }; - - return ( -
  • -
    -
    - - {channel.displayName} - - -
    - - {`Digest of ${channel.displayName} news.`} - -
    - -
  • - ); -}; - -// The Presidential Briefing is a separate Plus feature delivered via the Brief -// personalized digest. Plus users toggle the subscription; non-Plus users see a -// Plus upsell instead of the switch. -const BriefSettingsRow = (): ReactElement => { - const queryClient = useQueryClient(); - const { isPlus } = usePlusSubscription(); - const { - getPersonalizedDigest, - subscribePersonalizedDigest, - unsubscribePersonalizedDigest, - } = usePersonalizedDigest(); - const isSubscribed = !!getPersonalizedDigest( - UserPersonalizedDigestType.Brief, - ); - const inputId = 'headline-toggle-brief'; - - const onToggle = async () => { - if (isSubscribed) { - await unsubscribePersonalizedDigest({ - type: UserPersonalizedDigestType.Brief, - }); - } else { - await subscribePersonalizedDigest({ - type: UserPersonalizedDigestType.Brief, - hour: 9, - sendType: SendType.Daily, - }); - } - - await queryClient.invalidateQueries({ - queryKey: DAILY_HEADLINES_QUERY_KEY, - }); - }; - - return ( -
  • -
    - - Presidential Briefing - - - A daily briefing of what matters, generated just for you based on your - preferences and interests. - -
    - {isPlus ? ( - - ) : ( - - )} -
  • - ); -}; - -export const HeadlinesSettingsModal = ({ - onRequestClose, - ...props -}: HeadlinesSettingsModalProps): ReactElement => { - const { data, isPending } = useQuery(channelConfigurationsQueryOptions()); - - const rows = (data?.channelConfigurations ?? []).flatMap((channel) => { - const digestSource = channel.digest?.source; - if (!digestSource) { - return []; - } - const source: Source = { - ...digestSource, - type: SourceType.Machine, - public: true, - }; - return [{ channel, source }]; - }); - - return ( - - -
    -
    - - Pick which topical digests show up in your Headlines section. - -
    - {isPending ? ( -
    - -
    - ) : ( -
      - - {rows.map(({ channel, source }) => ( - - ))} -
    - )} -
    -
    - ); -}; diff --git a/packages/shared/src/features/daily/hooks/useDailyFeed.ts b/packages/shared/src/features/daily/hooks/useDailyFeed.ts deleted file mode 100644 index 1c1621e3f17..00000000000 --- a/packages/shared/src/features/daily/hooks/useDailyFeed.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useCallback, useContext, useMemo } from 'react'; -import type { ClientError } from 'graphql-request'; -import type { InfiniteData, QueryKey } from '@tanstack/react-query'; -import { useInfiniteQuery } from '@tanstack/react-query'; -import type { FeedData, FeedItemData } from '../../../graphql/feed'; -import { - DAILY_FEED_QUERY, - getFeedApiItemPost, - isFeedApiPostItem, - normalizeFeedPage, -} from '../../../graphql/feed'; -import type { Post } from '../../../graphql/posts'; -import { gqlClient } from '../../../graphql/common'; -import AuthContext from '../../../contexts/AuthContext'; -import { - generateQueryKey, - getNextPageParam, - RequestKey, - StaleTime, -} from '../../../lib/query'; -import { useUpdateQuery } from '../../../hooks/useUpdateQuery'; -import type { UpdateDailyPost } from '../optimisticMutations'; - -// Picks shows a fixed set of 5 posts (no pagination). -const PAGE_SIZE = 5; - -interface UseDailyFeed { - posts: Post[]; - fetchNextPage: () => Promise; - canFetchMore: boolean; - isFetchingNextPage: boolean; - isPending: boolean; - isError: boolean; - updatePost: UpdateDailyPost; -} - -export const useDailyFeed = (): UseDailyFeed => { - const { user, tokenRefreshed } = useContext(AuthContext); - const queryKey = useMemo( - () => generateQueryKey(RequestKey.DailyFeed, user), - [user], - ); - const [getFeedData, setFeedData] = useUpdateQuery>( - { queryKey }, - ); - - const feedQuery = useInfiniteQuery< - FeedItemData, - ClientError, - InfiniteData, - QueryKey, - string - >({ - queryKey, - queryFn: async ({ pageParam }) => { - const rawResult = await gqlClient.request(DAILY_FEED_QUERY, { - first: PAGE_SIZE, - after: pageParam, - loggedIn: !!user, - }); - - return normalizeFeedPage(rawResult); - }, - enabled: tokenRefreshed, - staleTime: StaleTime.Default, - initialPageParam: '', - getNextPageParam: ({ page }) => getNextPageParam(page?.pageInfo), - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }); - - const posts = useMemo( - () => - feedQuery.data?.pages.reduce((acc, { page }) => { - page.edges.forEach(({ node }) => { - const post = getFeedApiItemPost(node); - if (post) { - acc.push(post); - } - }); - return acc; - }, []) ?? [], - [feedQuery.data?.pages], - ); - - const updatePost = useCallback( - (postId, manipulate) => { - const current = getFeedData(); - - if (!current) { - return; - } - - current.pages.forEach((feedPage) => { - feedPage.page.edges.forEach((edge) => { - if (isFeedApiPostItem(edge.node) && edge.node.post.id === postId) { - Object.assign(edge.node.post, manipulate(edge.node.post)); - } - }); - }); - - setFeedData(current); - }, - [getFeedData, setFeedData], - ); - - return { - posts, - fetchNextPage: async () => { - await feedQuery.fetchNextPage(); - }, - canFetchMore: !!feedQuery.hasNextPage, - isFetchingNextPage: feedQuery.isFetchingNextPage, - isPending: feedQuery.isPending, - isError: feedQuery.isError, - updatePost, - }; -}; diff --git a/packages/shared/src/features/daily/optimisticMutations.ts b/packages/shared/src/features/daily/optimisticMutations.ts deleted file mode 100644 index a675a12e74a..00000000000 --- a/packages/shared/src/features/daily/optimisticMutations.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Post } from '../../graphql/posts'; -import { UserVote } from '../../graphql/posts'; -import type { UseVotePostProps } from '../../hooks/vote/types'; -import { voteMutationHandlers } from '../../hooks/vote/types'; -import type { UseBookmarkPostProps } from '../../hooks/useBookmarkPost'; - -/** - * Patches a single post (by id) inside a daily query cache. The daily feed and - * headlines queries aren't the caches the vote/bookmark hooks update by default, - * so each feature supplies its own updater and we reuse the standard - * `voteMutationHandlers` deltas to keep counts + state optimistic (and - * reversible on error), exactly like the feed cards do. - */ -export type UpdateDailyPost = ( - id: string, - manipulate: (post: Post) => Partial, -) => void; - -export const createVoteOnMutate = - (updatePost: UpdateDailyPost): UseVotePostProps['onMutate'] => - ({ id, vote }) => { - const mutationHandler = voteMutationHandlers[vote]; - - if (!mutationHandler) { - return undefined; - } - - let previousVote = UserVote.None; - updatePost(id, (post) => { - previousVote = post.userState?.vote ?? UserVote.None; - return mutationHandler(post) as Partial; - }); - - return () => { - const rollbackHandler = voteMutationHandlers[previousVote]; - - if (rollbackHandler) { - updatePost(id, (post) => rollbackHandler(post) as Partial); - } - }; - }; - -export const createBookmarkOnMutate = - (updatePost: UpdateDailyPost): UseBookmarkPostProps['onMutate'] => - ({ id }) => { - if (!id) { - return undefined; - } - - let previousBookmarked = false; - updatePost(id, (post) => { - previousBookmarked = !!post.bookmarked; - return { bookmarked: !post.bookmarked }; - }); - - return () => { - updatePost(id, () => ({ bookmarked: previousBookmarked })); - }; - }; diff --git a/packages/shared/src/features/giveback/actionPlatform.ts b/packages/shared/src/features/giveback/actionPlatform.ts deleted file mode 100644 index b13c069f26a..00000000000 --- a/packages/shared/src/features/giveback/actionPlatform.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { ComponentType } from 'react'; -// AndroidIcon is not re-exported by the icons barrel, so import it directly. -import { AndroidIcon } from '../../components/icons/Android'; -import { - AppleIcon, - BrowserGroupIcon, - CalendarIcon, - ChromeIcon, - DailyIcon, - DiscordIcon, - DiscussIcon, - DocsIcon, - EarthIcon, - EdgeIcon, - FeatherIcon, - GitHubIcon, - HashnodeIcon, - HotIcon, - LinkIcon, - LinkedInIcon, - MailIcon, - MegaphoneIcon, - MicrophoneIcon, - PlayIcon, - RedditIcon, - SitesIcon, - SlackIcon, - StackOverflowIcon, - StarIcon, - TelegramIcon, - TerminalIcon, - TrendingIcon, - TwitterIcon, - YoutubeIcon, -} from '../../components/icons'; -import type { IconProps } from '../../components/Icon'; - -interface ActionPlatformVisual { - name: string; - // Real brand logo rendered as an `` on the card (preferred for branded - // surfaces). When absent, the card renders the internal `Icon` instead. If the - // remote logo ever fails to load, the card also falls back to `Icon`, so a - // tile can never render broken or blank. - logoUrl?: string; - // Internal glyph used either as the primary visual for generic surfaces - // (blogs, newsletters, forums, events...) that have no single brand, or as the - // offline fallback for branded surfaces. - Icon: ComponentType; - // Some internal brand glyphs ship only a hardcoded-white SVG (no color or - // `currentColor` variant), invisible on the light tile. Flag those so the card - // can force the fallback glyph to a dark silhouette. - forceDark?: boolean; -} - -// Most brand logos come from Simple Icons' on-demand CDN (single, predictable -// slug -> official brand-colored glyph). A few brands Simple Icons drops for -// trademark reasons (LinkedIn, Slack, Edge) come from SVGL, the same open logo -// library the sponsor wall uses. -const simpleIcon = (slug: string): string => - `https://cdn.simpleicons.org/${slug}`; -const svglIcon = (slug: string): string => - `https://svgl.app/library/${slug}.svg`; - -const fallbackVisual: ActionPlatformVisual = { name: 'Link', Icon: LinkIcon }; - -// Real platform logos so each action reads as a growth move on a known surface -// (post on X, video on YouTube, ship on GitHub...). Keyed by the platform slug -// the backend stores on the action metadata. Branded surfaces get their actual -// logo; surfaces without a dedicated brand reuse the closest semantic glyph -// (reviews -> star, blogs -> globe, events -> calendar...). -const actionPlatformVisual: Record = { - x: { name: 'X', logoUrl: simpleIcon('x'), Icon: TwitterIcon }, - youtube: { - name: 'YouTube', - logoUrl: simpleIcon('youtube'), - Icon: YoutubeIcon, - }, - hashnode: { - name: 'Hashnode', - logoUrl: simpleIcon('hashnode'), - Icon: HashnodeIcon, - forceDark: true, - }, - github: { name: 'GitHub', logoUrl: simpleIcon('github'), Icon: GitHubIcon }, - reddit: { name: 'Reddit', logoUrl: simpleIcon('reddit'), Icon: RedditIcon }, - linkedin: { - name: 'LinkedIn', - logoUrl: svglIcon('linkedin'), - Icon: LinkedInIcon, - }, - app_store: { - name: 'App Store', - logoUrl: simpleIcon('appstore'), - Icon: AppleIcon, - forceDark: true, - }, - chrome_web_store: { - name: 'Chrome Web Store', - logoUrl: simpleIcon('googlechrome'), - Icon: ChromeIcon, - }, - daily_dev: { - name: 'daily.dev', - logoUrl: simpleIcon('dailydotdev'), - Icon: DailyIcon, - forceDark: true, - }, - edge_addons: { - name: 'Edge Add-ons', - logoUrl: svglIcon('edge'), - Icon: EdgeIcon, - }, - firefox_addons: { - name: 'Firefox Add-ons', - logoUrl: simpleIcon('firefoxbrowser'), - Icon: BrowserGroupIcon, - }, - google_play: { - name: 'Google Play', - logoUrl: simpleIcon('googleplay'), - Icon: AndroidIcon, - }, - trustpilot: { - name: 'Trustpilot', - logoUrl: simpleIcon('trustpilot'), - Icon: StarIcon, - }, - g2: { name: 'G2', logoUrl: simpleIcon('g2'), Icon: StarIcon }, - // Capterra has no logo on either CDN, so it keeps the semantic review glyph. - capterra: { name: 'Capterra', Icon: StarIcon }, - product_hunt: { - name: 'Product Hunt', - logoUrl: simpleIcon('producthunt'), - Icon: TrendingIcon, - }, - directory: { name: 'Directories', Icon: SitesIcon }, - medium: { name: 'Medium', logoUrl: simpleIcon('medium'), Icon: FeatherIcon }, - dev: { name: 'DEV', logoUrl: simpleIcon('devdotto'), Icon: TerminalIcon }, - blog: { name: 'Blog', Icon: EarthIcon }, - newsletter: { name: 'Newsletter', Icon: MailIcon }, - notion: { name: 'Notion', logoUrl: simpleIcon('notion'), Icon: DocsIcon }, - website: { name: 'Website', Icon: EarthIcon }, - hacker_news: { - name: 'Hacker News', - logoUrl: simpleIcon('ycombinator'), - Icon: HotIcon, - }, - stack_overflow: { - name: 'Stack Overflow', - logoUrl: simpleIcon('stackoverflow'), - Icon: StackOverflowIcon, - }, - discord: { - name: 'Discord', - logoUrl: simpleIcon('discord'), - Icon: DiscordIcon, - }, - slack: { name: 'Slack', logoUrl: svglIcon('slack'), Icon: SlackIcon }, - telegram: { - name: 'Telegram', - logoUrl: simpleIcon('telegram'), - Icon: TelegramIcon, - }, - indie_hackers: { - name: 'Indie Hackers', - logoUrl: simpleIcon('indiehackers'), - Icon: MegaphoneIcon, - }, - forum: { name: 'Forums', Icon: DiscussIcon }, - twitch: { name: 'Twitch', logoUrl: simpleIcon('twitch'), Icon: PlayIcon }, - podcast: { name: 'Podcast', Icon: MicrophoneIcon }, - event: { name: 'Events', Icon: CalendarIcon }, - wiki: { - name: 'Wikipedia', - logoUrl: simpleIcon('wikipedia'), - Icon: EarthIcon, - }, -}; - -// Every platform is mapped, but unknown/missing slugs fall back to a neutral -// link glyph so a card can never render a blank tile. -export const getActionPlatformVisual = ( - platform: string | null, -): ActionPlatformVisual => - (platform && actionPlatformVisual[platform.toLowerCase()]) || fallbackVisual; diff --git a/packages/shared/src/features/giveback/components/CauseEmblem.tsx b/packages/shared/src/features/giveback/components/CauseEmblem.tsx deleted file mode 100644 index a6ee9c43a02..00000000000 --- a/packages/shared/src/features/giveback/components/CauseEmblem.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useState } from 'react'; -import classNames from 'classnames'; -import { SparkleIcon } from '../../../components/icons'; -import { IconSize } from '../../../components/Icon'; -import type { ContributionCause } from '../types'; - -// Brand-tinted emblems so each cause reads as its own tile. -const emblemAccents = [ - 'bg-accent-cabbage-flat text-accent-cabbage-default', - 'bg-accent-avocado-flat text-accent-avocado-default', - 'bg-accent-onion-flat text-accent-onion-default', - 'bg-accent-bacon-flat text-accent-bacon-default', -]; - -interface CauseEmblemProps { - cause: ContributionCause; - // Position in the list, used to pick a stable brand tint for the fallback. - index: number; - className?: string; -} - -// Shows the cause's real logo on a light tile so each card reads as the actual -// nonprofit. Falls back to a brand-tinted sparkle emblem when the logo is -// missing or fails to load. -export const CauseEmblem = ({ - cause, - index, - className, -}: CauseEmblemProps): ReactElement => { - const [failed, setFailed] = useState(false); - - if (cause.logoUrl && !failed) { - return ( - - setFailed(true)} - className="size-7 object-contain" - /> - - ); - } - - return ( - - - - ); -}; diff --git a/packages/shared/src/features/giveback/components/GeoGateFallback.spec.tsx b/packages/shared/src/features/giveback/components/GeoGateFallback.spec.tsx deleted file mode 100644 index cb59cd566fa..00000000000 --- a/packages/shared/src/features/giveback/components/GeoGateFallback.spec.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { GeoGateFallback } from './GeoGateFallback'; - -it('explains that giveback is not available in the visitor region', () => { - render(); - - expect( - screen.getByRole('heading', { - name: 'Giveback is not available in your country yet', - }), - ).toBeInTheDocument(); - expect(screen.getByText('Giveback by daily.dev')).toBeInTheDocument(); - expect( - screen.getByText(/rolling out to more countries soon/i), - ).toBeInTheDocument(); -}); diff --git a/packages/shared/src/features/giveback/components/GeoGateFallback.tsx b/packages/shared/src/features/giveback/components/GeoGateFallback.tsx deleted file mode 100644 index 15270a93446..00000000000 --- a/packages/shared/src/features/giveback/components/GeoGateFallback.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { DailyIcon } from '../../../components/icons'; -import { cloudinaryGivebackPunchyStaring } from '../../../lib/image'; - -// Full-page gate. When Giveback isn't enabled for the visitor's region we block -// the whole experience and only explain, at a high level, what the campaign is. -// The gate decision (status.enabled) lives in GivebackPage - this is purely -// presentational so it can be rendered without the campaign queries. -export const GeoGateFallback = (): ReactElement => { - return ( -
    -
    - - - Punchy, the daily.dev mascot, looking on hopefully - - - - - Giveback by daily.dev - - - - - Giveback is not available in your country yet - - - - Giveback turns part of our growth budget into donations for causes the - community picks, funded by daily.dev, at no cost to you. - - - - It's in beta and rolling out to more countries soon. - - -
    - ); -}; diff --git a/packages/shared/src/features/giveback/components/GivebackActionCard.spec.tsx b/packages/shared/src/features/giveback/components/GivebackActionCard.spec.tsx deleted file mode 100644 index 97f96553c7d..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackActionCard.spec.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { GivebackActionCard } from './GivebackActionCard'; -import type { ContributionAction } from '../types'; -import { ContributionAssistType, ContributionSubmissionStatus } from '../types'; - -const makeAction = ( - overrides: Partial = {}, -): ContributionAction => ({ - id: 'a1', - categoryId: 'cat1', - title: 'Post about us on X', - description: null, - points: 5, - evidence: {}, - metadata: { - platform: 'x', - instructions: null, - externalUrl: null, - isLoveAction: false, - assistType: null, - }, - cooldownSeconds: null, - maxPerUser: null, - userCooldownEndsAt: null, - userCompletions: 0, - latestUserSubmission: null, - ...overrides, -}); - -const onSubmit = jest.fn(); - -beforeEach(() => jest.clearAllMocks()); - -it('renders an actionable card with the payout and platform, and submits', () => { - render(); - - expect(screen.getByText('Post about us on X')).toBeInTheDocument(); - expect(screen.getByText('+$5')).toBeInTheDocument(); - expect(screen.getByText('X')).toBeInTheDocument(); - - fireEvent.click( - screen.getByRole('button', { name: 'Submit proof for Post about us on X' }), - ); - expect(onSubmit).toHaveBeenCalledWith(makeAction()); -}); - -it('labels a referral action as an invite rather than a proof submission', () => { - const action = makeAction({ - title: 'Invite a friend', - metadata: { - platform: null, - instructions: null, - externalUrl: null, - isLoveAction: false, - assistType: ContributionAssistType.ReferralLink, - }, - }); - render(); - - fireEvent.click( - screen.getByRole('button', { name: 'Invite friends: Invite a friend' }), - ); - expect(onSubmit).toHaveBeenCalledWith(action); -}); - -it('keeps an uncapped action actionable after an approval', () => { - render( - , - ); - - expect(screen.queryByText('Done')).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Submit proof for Post about us on X' }), - ).toBeInTheDocument(); -}); - -it('locks a one-shot approved action into a non-interactive Done state', () => { - render( - , - ); - - expect(screen.getByText('Done')).toBeInTheDocument(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); -}); - -it('shows In review for a flagged submission and stays non-interactive', () => { - render( - , - ); - - expect(screen.getByText('In review')).toBeInTheDocument(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); -}); - -it('treats reaching the per-user cap as Done', () => { - render( - , - ); - - expect(screen.getByText('Done')).toBeInTheDocument(); -}); - -it('keeps a repeatable action actionable after an approval until the cap is hit', () => { - render( - , - ); - - expect(screen.queryByText('Done')).not.toBeInTheDocument(); - expect(screen.getByText('9 left')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Invite friends: Invite a friend' }), - ).toBeInTheDocument(); -}); - -it('keeps a rejected action submittable for a retry', () => { - render( - , - ); - - expect( - screen.getByRole('button', { name: 'Submit proof for Post about us on X' }), - ).toBeInTheDocument(); -}); - -it('shows the remaining runs for a repeatable action and stays actionable', () => { - render( - , - ); - - expect(screen.getByText('2 left')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Submit proof for Post about us on X' }), - ).toBeInTheDocument(); -}); - -it('hides the runs-left counter until the visitor has completed one', () => { - render( - , - ); - - expect(screen.queryByText(/\d+ left/)).not.toBeInTheDocument(); -}); - -it('locks a cooling-down action with an availability label', () => { - render( - , - ); - - expect(screen.getByText(/Available in/)).toBeInTheDocument(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); -}); - -it('ignores an elapsed cooldown and stays actionable', () => { - render( - , - ); - - expect(screen.queryByText(/Available in/)).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Submit proof for Post about us on X' }), - ).toBeInTheDocument(); -}); - -it('renders a love action with its appreciation tag instead of a payout', () => { - render( - , - ); - - expect(screen.getByText('Just for love')).toBeInTheDocument(); - expect(screen.queryByText('+$5')).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Show some love: Star us on GitHub' }), - ).toBeInTheDocument(); -}); diff --git a/packages/shared/src/features/giveback/components/GivebackActionCard.tsx b/packages/shared/src/features/giveback/components/GivebackActionCard.tsx deleted file mode 100644 index eb1304cd3ba..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackActionCard.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import type { ComponentType, ReactElement, ReactNode } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { IconSize } from '../../../components/Icon'; -import type { IconProps } from '../../../components/Icon'; -import { RefreshIcon, TimerIcon, VIcon } from '../../../components/icons'; -import type { ContributionAction } from '../types'; -import { ContributionAssistType, ContributionSubmissionStatus } from '../types'; -import { formatDonationAmount } from '../utils'; -import { getActionPlatformVisual } from '../actionPlatform'; -import { GivebackPlatformLogo } from './GivebackPlatformLogo'; - -interface GivebackActionCardProps { - action: ContributionAction; - onSubmit?: (action: ContributionAction) => void; -} - -interface StatusMeta { - label: string; - Icon: ComponentType; -} - -// Coarse "available in" copy for a cooled-down action. Rounds up to the nearest -// day/hour/minute so the card never claims an action is ready a tick early. -const formatCooldownRemaining = (endsAt: string): string => { - const minutes = Math.ceil((new Date(endsAt).getTime() - Date.now()) / 60000); - if (minutes >= 1440) { - return `${Math.ceil(minutes / 1440)}d`; - } - if (minutes >= 60) { - return `${Math.ceil(minutes / 60)}h`; - } - return `${Math.max(1, minutes)}m`; -}; - -// One sharp, explicit title carries the ask - no competing subtitle. The -// supporting details (payout, status, "just for love") sit in a calm top/bottom -// frame around it so the card stays easy to scan at a glance. -export const GivebackActionCard = ({ - action, - onSubmit, -}: GivebackActionCardProps): ReactElement => { - const { metadata, latestUserSubmission } = action; - const isLove = metadata.isLoveAction; - const isReferral = - metadata.assistType === ContributionAssistType.ReferralLink; - - const reachedMax = - action.maxPerUser != null && action.userCompletions >= action.maxPerUser; - const isInReview = - latestUserSubmission?.status === ContributionSubmissionStatus.Flagged; - // "Done" = the action is exhausted, i.e. its per-user cap is reached. A capped - // action (maxPerUser set) latches once completions hit the cap - a one-shot - // (cap 1) after its single approval, a repeatable one only at its ceiling. An - // uncapped action (maxPerUser null) is unlimited, matching the backend, so a - // single approval never marks it done (e.g. "Invite a friend" stays open). A - // pending submission still reads as "In review", and a rejected one stays - // clickable to retry. - const isDone = !isInReview && reachedMax; - // Cooldown only gates an action that would otherwise be actionable. - const onCooldown = - !isDone && - !isInReview && - !!action.userCooldownEndsAt && - new Date(action.userCooldownEndsAt).getTime() > Date.now(); - - // Repeatable actions surface how many runs are left, but only once the - // visitor has started: a fresh card reads as a normal action, not a tracker. - const { maxPerUser } = action; - const remaining = - maxPerUser != null ? Math.max(0, maxPerUser - action.userCompletions) : 0; - const showRemaining = - maxPerUser != null && - maxPerUser > 1 && - action.userCompletions > 0 && - !isDone && - remaining > 0; - - // Any non-actionable state shares the same dimmed, non-interactive treatment. - const isDimmed = isDone || isInReview || onCooldown; - const isInteractive = !isDimmed && !!onSubmit; - - const getInteractiveAriaLabel = (): string => { - if (isReferral) { - return `Invite friends: ${action.title}`; - } - if (isLove) { - return `Show some love: ${action.title}`; - } - return `Submit proof for ${action.title}`; - }; - const interactiveAriaLabel = getInteractiveAriaLabel(); - - const { - Icon, - name: platformName, - forceDark, - logoUrl, - } = getActionPlatformVisual(metadata.platform); - - const getStatusMeta = (): StatusMeta | null => { - if (isDone) { - return { label: 'Done', Icon: VIcon }; - } - if (isInReview) { - return { label: 'In review', Icon: TimerIcon }; - } - if (onCooldown && action.userCooldownEndsAt) { - return { - label: `Available in ${formatCooldownRemaining( - action.userCooldownEndsAt, - )}`, - Icon: TimerIcon, - }; - } - return null; - }; - const statusMeta = getStatusMeta(); - - // The top-right slot shows one of three mutually exclusive things: a status - // pill once acted on or cooling down, a soft "love" tag for no-reward actions, - // or the payout. - const renderTopRightMeta = (): ReactNode => { - if (statusMeta) { - return ( - - - - {statusMeta.label} - - - ); - } - - if (isLove) { - return ( - - Just for love - - ); - } - - return ( - - +{formatDonationAmount(action.points)} - - ); - }; - - const content: ReactNode = ( - <> - - - - - - - {platformName} - - - {renderTopRightMeta()} - - - - {action.title} - - - {(action.description || showRemaining) && ( - - {action.description && ( - - {action.description} - - )} - {showRemaining && ( - - - - {remaining} left - - - )} - - )} - - ); - - if (isInteractive) { - return ( - - ); - } - - // Acted on or cooling down: a flat, non-interactive tile. "Done" gets the - // dashed claimed outline; in-review and cooldown keep a solid surface so they - // read as temporary rather than finished. - return ( - - {content} - - ); -}; diff --git a/packages/shared/src/features/giveback/components/GivebackActionCatalog.spec.tsx b/packages/shared/src/features/giveback/components/GivebackActionCatalog.spec.tsx deleted file mode 100644 index 68dc92e1422..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackActionCatalog.spec.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { GivebackActionCatalog } from './GivebackActionCatalog'; -import { useContributionActions } from '../hooks/useContributionActions'; -import type { ContributionAction } from '../types'; - -jest.mock('../hooks/useContributionActions'); -jest.mock('./GivebackActionSubmissionModal', () => ({ - GivebackActionSubmissionModal: ({ - action, - }: { - action: ContributionAction; - }): JSX.Element =>
    {action.title}
    , -})); - -const mockUseActions = useContributionActions as jest.MockedFunction< - typeof useContributionActions ->; - -const makeAction = ( - overrides: Partial = {}, -): ContributionAction => ({ - id: 'a1', - categoryId: 'cat1', - title: 'Action', - description: null, - points: 5, - evidence: {}, - metadata: { - platform: 'x', - instructions: null, - externalUrl: null, - isLoveAction: false, - assistType: null, - }, - cooldownSeconds: null, - maxPerUser: null, - userCooldownEndsAt: null, - userCompletions: 0, - latestUserSubmission: null, - ...overrides, -}); - -const categories = [ - { id: 'cat1', title: 'Social' }, - { id: 'cat2', title: 'Reviews' }, -]; - -const mockReturn = (actions: ContributionAction[], isPending = false) => - mockUseActions.mockReturnValue({ - actions, - categories, - rewardTiers: [], - claimedRewardIds: [], - isPending, - }); - -beforeEach(() => jest.clearAllMocks()); - -it('renders a skeleton while loading', () => { - mockUseActions.mockReturnValue({ - actions: [], - categories: [], - rewardTiers: [], - claimedRewardIds: [], - isPending: true, - }); - render(); - - expect( - screen.getByRole('status', { name: 'Loading actions' }), - ).toBeInTheDocument(); -}); - -it('shows an empty message when there are no actions', () => { - mockReturn([]); - render(); - - expect( - screen.getByText('No actions are available yet. Check back soon.'), - ).toBeInTheDocument(); -}); - -it('filters the grid by the selected category', () => { - mockReturn([ - makeAction({ id: 'a1', title: 'Post on X', categoryId: 'cat1' }), - makeAction({ id: 'a2', title: 'Leave a review', categoryId: 'cat2' }), - ]); - render(); - - expect(screen.getByText('Post on X')).toBeInTheDocument(); - expect(screen.getByText('Leave a review')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Reviews' })); - - expect(screen.queryByText('Post on X')).not.toBeInTheDocument(); - expect(screen.getByText('Leave a review')).toBeInTheDocument(); -}); - -it('caps the initial grid and expands on show more', () => { - const actions = Array.from({ length: 15 }, (_, index) => - makeAction({ id: `a${index}`, title: `Action ${index}` }), - ); - mockReturn(actions); - render(); - - expect(screen.queryByText('Action 12')).not.toBeInTheDocument(); - - fireEvent.click( - screen.getByRole('button', { name: 'Show more actions (3)' }), - ); - - expect(screen.getByText('Action 12')).toBeInTheDocument(); -}); - -it('renders love actions in their own group', () => { - mockReturn([ - makeAction({ id: 'a1', title: 'Post on X' }), - makeAction({ - id: 'a2', - title: 'Star us on GitHub', - metadata: { - platform: 'github', - instructions: null, - externalUrl: null, - isLoveAction: true, - assistType: null, - }, - }), - ]); - render(); - - // The group heading plus the love card's own tag both read "Just for love". - expect(screen.getAllByText('Just for love')).toHaveLength(2); - expect(screen.getByText('Star us on GitHub')).toBeInTheDocument(); -}); - -it('opens the submission modal when a card is chosen', () => { - mockReturn([makeAction({ id: 'a1', title: 'Post on X' })]); - render(); - - fireEvent.click( - screen.getByRole('button', { name: 'Submit proof for Post on X' }), - ); - - expect(screen.getByTestId('submission-modal')).toHaveTextContent('Post on X'); -}); diff --git a/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx b/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx deleted file mode 100644 index 685300777b9..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../../../components/typography/Typography'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { ButtonIconPosition } from '../../../components/buttons/common'; -import { ArrowIcon } from '../../../components/icons'; -import { useLogContext } from '../../../contexts/LogContext'; -import { LogEvent } from '../../../lib/log'; -import type { ContributionAction } from '../types'; -import { useContributionActions } from '../hooks/useContributionActions'; -import { GivebackActionCard } from './GivebackActionCard'; -import { GivebackActionSubmissionModal } from './GivebackActionSubmissionModal'; -import { GivebackFilterChip } from './GivebackFilterChip'; - -const ALL_FILTER = 'all'; - -// Keep the initial grid short so the tab opens scannable; the rest expand on -// demand. 12 fills four clean rows on the 3-column breakpoint. -const INITIAL_VISIBLE_ACTIONS = 12; - -const ActionGrid = ({ - actions, - onSubmit, -}: { - actions: ContributionAction[]; - onSubmit: (action: ContributionAction) => void; -}): ReactElement => ( -
    - {actions.map((action) => ( - - ))} -
    -); - -interface GivebackActionCatalogProps { - // Scrolls the tab strip back to the top so a filtered list always starts in - // view (no jump from the previous scroll position). - onFilter?: () => void; -} - -export const GivebackActionCatalog = ({ - onFilter, -}: GivebackActionCatalogProps): ReactElement => { - const { logEvent } = useLogContext(); - const { actions, categories, isPending } = useContributionActions(true); - const [selectedCategory, setSelectedCategory] = useState(ALL_FILTER); - const [showAll, setShowAll] = useState(false); - const [submissionAction, setSubmissionAction] = - useState(null); - - // A new filter always opens to the short, scannable list. - useEffect(() => { - setShowAll(false); - }, [selectedCategory]); - - const openAction = (action: ContributionAction) => { - logEvent({ - event_name: LogEvent.OpenGivebackAction, - target_id: action.id, - extra: JSON.stringify({ - platform: action.metadata.platform, - points: action.points, - is_love: action.metadata.isLoveAction, - }), - }); - setSubmissionAction(action); - }; - - const selectCategory = (categoryId: string) => { - logEvent({ - event_name: LogEvent.FilterGivebackActions, - extra: JSON.stringify({ category_id: categoryId }), - }); - setSelectedCategory(categoryId); - onFilter?.(); - }; - - const toggleShowAll = () => { - setShowAll((value) => { - if (!value) { - logEvent({ event_name: LogEvent.ClickGivebackShowMoreActions }); - } - return !value; - }); - }; - - const { paidActions, loveActions } = useMemo(() => { - const paid: ContributionAction[] = []; - const love: ContributionAction[] = []; - actions.forEach((action) => { - (action.metadata.isLoveAction ? love : paid).push(action); - }); - return { paidActions: paid, loveActions: love }; - }, [actions]); - - const filteredPaid = useMemo( - () => - selectedCategory === ALL_FILTER - ? paidActions - : paidActions.filter( - (action) => action.categoryId === selectedCategory, - ), - [paidActions, selectedCategory], - ); - - if (isPending) { - return ( -
    - {Array.from({ length: 6 }).map((_, index) => ( -
    - ))} -
    - ); - } - - if (!actions.length) { - return ( - - No actions are available yet. Check back soon. - - ); - } - - const visiblePaid = showAll - ? filteredPaid - : filteredPaid.slice(0, INITIAL_VISIBLE_ACTIONS); - const hiddenCount = filteredPaid.length - visiblePaid.length; - - return ( - - - selectCategory(ALL_FILTER)} - /> - {categories.map((category) => ( - selectCategory(category.id)} - /> - ))} - - - {filteredPaid.length > 0 ? ( - - - {hiddenCount > 0 && ( - - - - )} - - ) : ( - - - No actions match this filter - - - Try another filter. - - - )} - - {loveActions.length > 0 && ( - - - - Just for love - - - No donation rides on these. They just help us out. We'd love - you for it. - - - - - )} - - {submissionAction && ( - setSubmissionAction(null)} - /> - )} - - ); -}; diff --git a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.spec.tsx b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.spec.tsx deleted file mode 100644 index 8bbe41d78c0..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.spec.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { QueryClient } from '@tanstack/react-query'; -import { GivebackActionSubmissionModal } from './GivebackActionSubmissionModal'; -import { TestBootProvider } from '../../../../__tests__/helpers/boot'; -import loggedUser from '../../../../__tests__/fixture/loggedUser'; -import { useReferralCampaign } from '../../../hooks/referral/useReferralCampaign'; -import { useContributionActionLinks } from '../hooks/useContributionActionLinks'; -import type { ContributionAction } from '../types'; -import { ContributionAssistType } from '../types'; - -jest.mock('../hooks/useSubmitContributionAction', () => ({ - useSubmitContributionAction: () => ({ submit: jest.fn(), isPending: false }), -})); - -jest.mock('../../../hooks/useToastNotification', () => ({ - ...jest.requireActual('../../../hooks/useToastNotification'), - useToastNotification: () => ({ displayToast: jest.fn() }), -})); - -jest.mock('../../../contexts/LogContext', () => ({ - ...jest.requireActual('../../../contexts/LogContext'), - useLogContext: () => ({ logEvent: jest.fn() }), -})); - -jest.mock('../../../hooks/referral/useReferralCampaign', () => ({ - ...jest.requireActual('../../../hooks/referral/useReferralCampaign'), - useReferralCampaign: jest.fn(), -})); - -jest.mock('../hooks/useContributionActionLinks', () => ({ - useContributionActionLinks: jest.fn(), -})); - -const mockedUseReferralCampaign = useReferralCampaign as jest.Mock; -const mockedUseContributionActionLinks = - useContributionActionLinks as jest.Mock; - -beforeEach(() => { - mockedUseReferralCampaign.mockReturnValue({ - url: 'https://dly.to/abc', - isReady: true, - }); - mockedUseContributionActionLinks.mockReturnValue({ - links: [], - isPending: false, - isFetching: false, - shuffle: jest.fn(), - }); -}); - -const makeAction = ( - overrides: Partial = {}, -): ContributionAction => ({ - id: 'a1', - categoryId: 'cat1', - title: 'Invite a friend to daily.dev', - description: null, - points: 150, - evidence: {}, - metadata: { - platform: null, - instructions: null, - externalUrl: null, - isLoveAction: false, - assistType: ContributionAssistType.ReferralLink, - }, - cooldownSeconds: null, - maxPerUser: null, - userCooldownEndsAt: null, - userCompletions: 0, - latestUserSubmission: null, - ...overrides, -}); - -const renderModal = (action: ContributionAction): ReturnType => { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - return render( - - - , - ); -}; - -it('shows the copyable invite link and no proof submission for a referral action', () => { - renderModal(makeAction()); - - expect(screen.getByDisplayValue('https://dly.to/abc')).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Submit for review' }), - ).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); -}); - -it('surfaces how many invited friends have counted so far', () => { - renderModal(makeAction({ userCompletions: 3 })); - - expect(screen.getByText(/3 credited so far/)).toBeInTheDocument(); -}); - -it('shows a spinner instead of the fallback link while the invite link loads', () => { - mockedUseReferralCampaign.mockReturnValue({ url: undefined, isReady: false }); - renderModal(makeAction()); - - expect( - screen.getByRole('status', { name: 'Loading your invite link' }), - ).toBeInTheDocument(); - expect( - screen.queryByDisplayValue('https://daily.dev'), - ).not.toBeInTheDocument(); -}); - -const makeLinkPoolAction = (): ContributionAction => - makeAction({ - title: 'Mention daily.dev in a relevant Reddit discussion', - evidence: { url: { required: true } }, - metadata: { - platform: 'reddit', - instructions: null, - externalUrl: null, - isLoveAction: false, - assistType: ContributionAssistType.LinkPool, - }, - }); - -it('surfaces suggested pool threads and still asks for proof for a link_pool action', () => { - mockedUseContributionActionLinks.mockReturnValue({ - links: [ - { - id: 'l1', - url: 'https://reddit.com/r/webdev/comments/1', - label: 'r/webdev: keep up with dev news', - }, - { id: 'l2', url: 'https://reddit.com/r/x/comments/2', label: null }, - ], - isPending: false, - isFetching: false, - shuffle: jest.fn(), - }); - - renderModal(makeLinkPoolAction()); - - expect(screen.getByText('Suggested threads')).toBeInTheDocument(); - const firstThread = screen.getByText('r/webdev: keep up with dev news'); - expect(firstThread.closest('a')).toHaveAttribute( - 'href', - 'https://reddit.com/r/webdev/comments/1', - ); - // A label-less link falls back to its URL. - expect( - screen.getByText('https://reddit.com/r/x/comments/2'), - ).toBeInTheDocument(); - // link_pool keeps the proof flow (unlike referral). - expect( - screen.getByRole('button', { name: 'Submit for review' }), - ).toBeInTheDocument(); -}); - -it('shuffles the pool on request', () => { - const shuffle = jest.fn(); - mockedUseContributionActionLinks.mockReturnValue({ - links: [ - { id: 'l1', url: 'https://reddit.com/r/webdev/comments/1', label: 'One' }, - ], - isPending: false, - isFetching: false, - shuffle, - }); - - renderModal(makeLinkPoolAction()); - - fireEvent.click(screen.getByRole('button', { name: /shuffle/i })); - expect(shuffle).toHaveBeenCalled(); -}); - -it('reserves space with placeholder rows while the pool loads', () => { - mockedUseContributionActionLinks.mockReturnValue({ - links: [], - isPending: true, - isFetching: false, - shuffle: jest.fn(), - }); - - renderModal(makeLinkPoolAction()); - - expect( - screen.getByRole('status', { name: 'Loading suggested threads' }), - ).toBeInTheDocument(); -}); - -it('disables shuffle while a fresh set is loading', () => { - mockedUseContributionActionLinks.mockReturnValue({ - links: [ - { id: 'l1', url: 'https://reddit.com/r/webdev/comments/1', label: 'One' }, - ], - isPending: false, - isFetching: true, - shuffle: jest.fn(), - }); - - renderModal(makeLinkPoolAction()); - - expect(screen.getByRole('button', { name: /shuffle/i })).toBeDisabled(); -}); diff --git a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx deleted file mode 100644 index 10eba460226..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx +++ /dev/null @@ -1,698 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { RootPortal } from '../../../components/tooltips/Portal'; -import { OpenLinkIcon, RefreshIcon } from '../../../components/icons'; -import { uploadContentImage } from '../../../graphql/posts'; -import { useToastNotification } from '../../../hooks/useToastNotification'; -import { useLogContext } from '../../../contexts/LogContext'; -import { LogEvent } from '../../../lib/log'; -import { labels } from '../../../lib/labels'; -import { anchorDefaultRel } from '../../../lib/strings'; -import { link as appLinks } from '../../../lib/links'; -import { - useReferralCampaign, - ReferralCampaignKey, -} from '../../../hooks/referral/useReferralCampaign'; -import { InviteLinkInput } from '../../../components/referral/InviteLinkInput'; -import { Loader } from '../../../components/Loader'; -import { ElementPlaceholder } from '../../../components/ElementPlaceholder'; -import type { ContributionAction } from '../types'; -import { ContributionAssistType } from '../types'; -import { formatDonationAmount } from '../utils'; -import { getActionPlatformVisual } from '../actionPlatform'; -import { useSubmitContributionAction } from '../hooks/useSubmitContributionAction'; -import { useContributionActionLinks } from '../hooks/useContributionActionLinks'; -import { GivebackScreenshotField } from './GivebackScreenshotField'; -import { GivebackPlatformLogo } from './GivebackPlatformLogo'; - -interface GivebackActionSubmissionModalProps { - action: ContributionAction; - onClose: () => void; -} - -// Instructions may arrive as one paragraph or as several lines (one step each). -// Split on line breaks so multi-line how-tos render as a numbered checklist the -// user can follow top to bottom. -const toInstructionSteps = (instructions: string): string[] => - instructions - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - -// The explicit "what we're asking you to do" block at the top of every action. -// Leads with the platform identity and the reward, states the ask in a big -// title, and hands the user a one-tap way to go start it on the real surface. -const ActionBrief = ({ - action, - titleId, -}: { - action: ContributionAction; - titleId: string; -}): ReactElement => { - const { metadata } = action; - const isLove = metadata.isLoveAction; - const { - Icon, - name: platformName, - forceDark, - logoUrl, - } = getActionPlatformVisual(metadata.platform); - - return ( - - - - - - - - {platformName} - - - {isLove ? ( - - - Just for love - - - ) : ( - - - +{formatDonationAmount(action.points)} - - - to your causes - - - )} - - - - - {action.title} - - {action.description && ( - - {action.description} - - )} - - - {metadata.externalUrl && ( - - )} - - ); -}; - -// The full how-to, surfaced prominently (not as fine print) so the requirement -// is impossible to miss. Multi-line instructions become a numbered checklist. -const InstructionsBlock = ({ - instructions, -}: { - instructions: string; -}): ReactElement => { - const steps = toInstructionSteps(instructions); - - return ( - - - How to complete it - - {steps.length > 1 ? ( -
      - {steps.map((step, index) => ( -
    1. - - {index + 1} - - - {step} - -
    2. - ))} -
    - ) : ( - - {instructions} - - )} -
    - ); -}; - -// Referral actions are credited automatically when an invited friend activates, -// so there's no proof to submit. Instead we hand the user their own invite link -// to copy and share, and show how many friends have counted so far. -const ReferralPanel = ({ - action, -}: { - action: ContributionAction; -}): ReactElement => { - const { url, isReady } = useReferralCampaign({ - campaignKey: ReferralCampaignKey.Generic, - }); - // Wait for the personalized link rather than flashing the daily.dev fallback; - // once the query settles we show the real URL (or the fallback for the rare - // case it resolves empty). - const isLoading = !url && !isReady; - const inviteLink = url || appLinks.referral.defaultUrl; - const friendsCredited = action.userCompletions; - - return ( - - - Your invite link - - {isLoading ? ( - // Match InviteLinkInput's h-12 so swapping in the field causes no shift. - - - - ) : ( - - )} - - {friendsCredited > 0 - ? `Points land automatically when a friend joins and gets going — ${friendsCredited} credited so far.` - : 'Points land automatically when a friend joins and gets going.'} - - - ); -}; - -// Shared box for both real and skeleton pool rows. The fixed min height keeps -// the two identical so swapping the skeleton for real links causes no shift. -const POOL_ROW_CLASS = - 'flex min-h-10 items-center gap-2 rounded-12 border border-border-subtlest-tertiary bg-background-default px-3 py-2'; -// Mirrors the backend's default handful size, so the skeleton reserves the exact -// height the loaded list will occupy. -const POOL_PLACEHOLDER_KEYS = ['s0', 's1', 's2', 's3', 's4']; - -// link_pool actions carry a curated pool of targets (e.g. Reddit threads). We -// surface a randomized handful the user can open, with a shuffle for a fresh set, -// then they submit their own comment link as proof below. -const LinkPoolPanel = ({ - action, -}: { - action: ContributionAction; -}): ReactElement => { - const { logEvent } = useLogContext(); - const { links, isPending, isFetching, shuffle } = useContributionActionLinks({ - actionId: action.id, - enabled: true, - }); - - const onShuffle = () => { - logEvent({ - event_name: LogEvent.ShuffleGivebackPoolLinks, - target_id: action.id, - }); - shuffle(); - }; - - const renderBody = (): ReactElement => { - if (isPending) { - return ( - - {POOL_PLACEHOLDER_KEYS.map((key) => ( -
    - - -
    - ))} -
    - ); - } - - if (links.length === 0) { - return ( - - No suggestions right now. Pick any relevant thread and share your - comment below. - - ); - } - - return ( - - {links.map((poolLink) => ( - - logEvent({ - event_name: LogEvent.ClickGivebackPoolLink, - target_id: action.id, - extra: JSON.stringify({ url: poolLink.url }), - }) - } - className={`${POOL_ROW_CLASS} transition-colors hover:bg-surface-hover`} - > - - {poolLink.label || poolLink.url} - - - - - - ))} - - ); - }; - - return ( - - - - Suggested threads - - {links.length > 0 && ( - - )} - - {renderBody()} - - ); -}; - -export const GivebackActionSubmissionModal = ({ - action, - onClose, -}: GivebackActionSubmissionModalProps): ReactElement => { - const { displayToast } = useToastNotification(); - const { logEvent } = useLogContext(); - const { submit, isPending } = useSubmitContributionAction(); - const linkInputId = `giveback-proof-link-${action.id}`; - const noteInputId = `giveback-proof-note-${action.id}`; - - const [link, setLink] = useState(''); - const [screenshotPreview, setScreenshotPreview] = useState(); - const [screenshotUrl, setScreenshotUrl] = useState(); - const [isUploading, setIsUploading] = useState(false); - const [note, setNote] = useState(''); - const [isSubmitted, setIsSubmitted] = useState(false); - - const { evidence, metadata } = action; - const isLove = metadata.isLoveAction; - const isReferral = - metadata.assistType === ContributionAssistType.ReferralLink; - const isLinkPool = metadata.assistType === ContributionAssistType.LinkPool; - const showUrl = !!evidence.url; - const showScreenshot = !!evidence.screenshot; - const showNote = !!evidence.note; - - const canSubmit = useMemo(() => { - if (isUploading) { - return false; - } - const hasLink = !evidence.url?.required || link.trim().length > 0; - const hasScreenshot = !evidence.screenshot?.required || !!screenshotUrl; - const hasNote = !evidence.note?.required || note.trim().length > 0; - - return hasLink && hasScreenshot && hasNote; - }, [evidence, isUploading, link, note, screenshotUrl]); - - const onScreenshotSelect = async (base64: string, file: File) => { - setScreenshotPreview(base64); - setIsUploading(true); - try { - const url = await uploadContentImage(file); - setScreenshotUrl(url); - } catch { - setScreenshotPreview(undefined); - setScreenshotUrl(undefined); - displayToast(labels.error.generic); - } finally { - setIsUploading(false); - } - }; - - const clearScreenshot = () => { - setScreenshotPreview(undefined); - setScreenshotUrl(undefined); - }; - - // Close on Escape, matching the backdrop click below. - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - } - }; - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [onClose]); - - const onSubmit = async () => { - if (!canSubmit) { - return; - } - - try { - await submit({ - actionId: action.id, - evidence: { - url: link.trim() || undefined, - screenshotUrl: screenshotUrl || undefined, - note: note.trim() || undefined, - }, - }); - logEvent({ - event_name: LogEvent.SubmitGivebackAction, - target_id: action.id, - extra: JSON.stringify({ - platform: metadata.platform, - points: action.points, - has_url: !!link.trim(), - has_screenshot: !!screenshotUrl, - has_note: !!note.trim(), - }), - }); - setIsSubmitted(true); - } catch { - logEvent({ - event_name: LogEvent.SubmitGivebackActionError, - target_id: action.id, - }); - displayToast(labels.error.generic); - } - }; - - const onLoveAcknowledge = () => { - logEvent({ - event_name: LogEvent.ClickGivebackLoveAction, - target_id: action.id, - extra: JSON.stringify({ platform: metadata.platform }), - }); - onClose(); - }; - - // Referral and love actions have nothing to submit — both close with a single - // acknowledge button. Only the proof flow shows the cancel/submit pair. - const renderFooterActions = (): ReactElement => { - if (isReferral) { - return ( - - ); - } - - if (isLove) { - return ( - - ); - } - - return ( - <> - - {!isSubmitted && ( - - )} - - ); - }; - - return ( - -
    - {/* Full-bleed backdrop target: a real button so it closes on click and - keyboard, while the dialog sits above it and is unaffected. */} -