diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index a078ed09c1c..bfeccbaabdf 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -70,6 +70,16 @@ import ReadingReminderFeedHero from './marketing/banners/ReadingReminderFeedHero import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; import { useReaderModalEligibility } from './post/reader/hooks/useReaderModalEligibility'; import { useQuestDashboard } from '../hooks/useQuestDashboard'; +import { GoogleCloudBlogCard } from '../features/googleCloudTakeover/GoogleCloudBlogCard'; +import { GoogleCloudEngagementCard } from '../features/googleCloudTakeover/GoogleCloudEngagementCard'; +import { GoogleCloudHeadAd } from '../features/googleCloudTakeover/GoogleCloudHeadAd'; +import { GoogleCloudStrip } from '../features/googleCloudTakeover/GoogleCloudStrip'; +import { + googleCloudPrependedCards, + googleCloudStripRow, + googleCloudTakeoverEnabled, +} from '../features/googleCloudTakeover/config'; +import { isTesting } from '../lib/constants'; const FeedErrorScreen = dynamic( () => import(/* webpackChunkName: "feedErrorScreen" */ './FeedErrorScreen'), @@ -203,6 +213,21 @@ export default function Feed({ const isSquadFeed = feedName === OtherFeedPage.Squad; const trackedFeedFinish = useRef(false); const isMyFeed = feedName === SharedFeedPage.MyFeed; + // DEMO: render the Google Cloud takeover on the main home feeds only. + // Excluded from the test env so it doesn't distort existing feed specs; + // still live in dev and on the production preview deploy. + const showGoogleCloudTakeover = + googleCloudTakeoverEnabled && + !isTesting && + (feedName === SharedFeedPage.MyFeed || + feedName === SharedFeedPage.Popular || + // The advertiser takeover follows the user onto a tag feed: both the + // standard tag page (/tags/ai → OtherFeedPage.Tag) and the explore-tag + // feed reached via the feed tab bar (/explore/ai → ExploreTag) carry the + // Google Cloud engagement placements. + feedName === OtherFeedPage.Tag || + feedName === OtherFeedPage.ExploreTag) && + !isHorizontal; const showAcquisitionForm = isMyFeed && (routerQuery?.[acquisitionKey] as string)?.toLocaleLowerCase() === 'true' && @@ -292,7 +317,10 @@ export default function Feed({ firstSlotOffset: Number(showFirstSlotCard), disableTopHero: isV2, settings: { - disableAds, + // DEMO: suppress the feed's organic ads during the takeover so the + // only ad is the injected Google Cloud slot (avoids extra ads that + // non-Plus users would otherwise see). + disableAds: disableAds || showGoogleCloudTakeover, staticAd, adPostLength: isSquadFeed ? 2 : undefined, showAcquisitionForm, @@ -309,6 +337,29 @@ export default function Feed({ const { onMenuClick, postMenuIndex, postMenuLocation } = useFeedContextMenu(); const useList = isListMode && numCards > 1; const virtualizedNumCards = useList ? 1 : numCards; + // Find the item index before which the strip should render so it starts on a + // whole grid row (no empty cells above it). The takeover forces every feed + // item to a single cell, so the cells rendered before item `i` are just the + // fixed injected cards (the prepended blog + engagement cards, plus the head + // ad inserted before item 0) plus `i`. Stop at the first column-0 boundary + // at/after the target row. + const googleCloudStripBeforeIndex = useMemo(() => { + if (!showGoogleCloudTakeover || virtualizedNumCards < 1) { + return -1; + } + const targetCells = googleCloudStripRow * virtualizedNumCards; + const injectedBeforeItems = googleCloudPrependedCards + 1; // + head ad + for (let i = 0; i < items.length; i += 1) { + const cellsBefore = injectedBeforeItems + i; + if ( + cellsBefore >= targetCells && + cellsBefore % virtualizedNumCards === 0 + ) { + return i; + } + } + return -1; + }, [showGoogleCloudTakeover, virtualizedNumCards, items]); // Experiment: let the browser skip layout/paint for off-screen cards on long // vertical feeds. Horizontal carousels are short and scroll on the other axis, @@ -689,9 +740,20 @@ export default function Feed({ }} /> )} + {showGoogleCloudTakeover && ( + <> + + + + )} {items.map((item, index) => { const placement = itemPlacements[index]; - const { colSpan } = placement; + // DEMO: the takeover injects extra cells (blog card, ad), which + // shifts the grid and desyncs the feed's wide-card placements, + // leaving empty cells. Force single-column cards so every cell + // packs cleanly; the only full-row item is the GCP strip, which + // is positioned on a row boundary. + const colSpan = showGoogleCloudTakeover ? 1 : placement.colSpan; const isWidened = colSpan > 1; const wideColSpan = isWidened && (colSpan === 2 || colSpan === 3 || colSpan === 4) @@ -764,15 +826,30 @@ export default function Feed({ : undefined, }} > - {showPromoBanner && index === indexWhenShowingPromoBanner && ( - + {showPromoBanner && + !showGoogleCloudTakeover && + index === indexWhenShowingPromoBanner && ( + + )} + {showGoogleCloudTakeover && index === 0 && ( + )} + {showGoogleCloudTakeover && + index === googleCloudStripBeforeIndex && ( + + )} {renderedItem} ); diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index e3ab6d50043..7a0ae7e1645 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -18,7 +18,7 @@ import { PromptElement } from './modals/Prompt'; import { useNotificationParams } from '../hooks/useNotificationParams'; import { useAuthContext } from '../contexts/AuthContext'; import { SharedFeedPage } from './utilities'; -import { isTesting, onboardingUrl } from '../lib/constants'; +import { isTesting, onboardingUrl, webappUrl } from '../lib/constants'; import { useBanner } from '../hooks/useBanner'; import { useGrowthBookContext } from './GrowthBookProvider'; import { @@ -32,6 +32,8 @@ import { useFeedName } from '../hooks/feed/useFeedName'; import { AuthTriggers } from '../lib/auth'; import PlusMobileEntryBanner from './marketing/banners/PlusMobileEntryBanner'; import usePlusEntry from '../hooks/usePlusEntry'; +import { GoogleCloudAnnouncementBar } from '../features/googleCloudTakeover/GoogleCloudAnnouncementBar'; +import { googleCloudTakeoverEnabled } from '../features/googleCloudTakeover/config'; import { SearchProvider } from '../contexts/search/SearchContext'; import { SpotlightProvider } from './spotlight/SpotlightContext'; import { SpotlightHost } from './spotlight/SpotlightHost'; @@ -123,6 +125,13 @@ function MainLayoutComponent({ feedName: currentFeedName, }); const { plusEntryAnnouncementBar } = usePlusEntry(); + // DEMO: Google Cloud takeover bar, rendered at the app layout level so it + // sits above/outside the feed's floating-card box on the home feed. Gate on + // `router.pathname` (the underlying page route) rather than `asPath`. When a + // post modal opens it only changes `asPath`/query to `/posts/...` while the + // home route stays `/`, so the bar stays put and doesn't cause a layout shift. + const showGoogleCloudBar = + googleCloudTakeoverEnabled && !isTesting && router?.pathname === webappUrl; const isLaptop = useViewSize(ViewSize.Laptop); const isLaptopXL = useViewSize(ViewSize.LaptopXL); const { screenCenteredOnMobileLayout } = useFeedLayout(); @@ -349,6 +358,9 @@ function MainLayoutComponent({ )} {sidebarOwnsHeader ? (
+ {showGoogleCloudBar && ( + + )} {showHomepageTopBanners && ( )} @@ -373,7 +385,12 @@ function MainLayoutComponent({
) : ( - children + <> + {showGoogleCloudBar && ( + + )} + {children} + )} {!hideFeedbackWidget && !sidebarOwnsHeader && } diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 9a63092fe53..771dae3d15a 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useMemo } from 'react'; import classNames from 'classnames'; import type { ActionButtonsProps } from './ActionButtons'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; @@ -17,6 +17,7 @@ import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; // Full-bleed cover: drop side padding/bottom margin and round only the bottom // corners so the image meets the card edges. Height/crop are untouched. @@ -29,16 +30,19 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1'; // hover/pressed colors are left to each `btn-tertiary-*` class so icons keep // their brand tint on hover, matching the standard ActionButtons. const pillClasses = classNames( - 'pointer-events-auto flex h-10 w-full items-center overflow-hidden px-1', + 'pointer-events-auto flex h-10 w-full items-center justify-between gap-0.5 overflow-hidden px-1', 'rounded-12 border border-border-subtlest-tertiary', 'bg-blur-bg text-text-primary backdrop-blur-xl backdrop-saturate-150', '[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]', '[&_.btn]:[--button-default-color:var(--theme-text-primary)]', ); -// Every action gets an equal-width centered slot so the icons stay evenly spaced -// across the pill regardless of upvote/comment counts widening a button. -const slotClasses = 'flex min-w-0 flex-1 items-center justify-center'; +// Each action sizes to its content (so the count inside the upvote/comment +// button isn't clipped) and the row spreads them with `justify-between`. An +// equal-width `flex-1` layout forced every slot to 1/N of the pill, which is +// narrower than a count-bearing button on tight (e.g. 5-column) cards — so the +// buttons overflowed their slots and overlapped. +const slotClasses = 'flex min-w-0 shrink items-center justify-center'; // Dark glow behind the pill so it stays readable over busy cover images. Fixed // pepper tint in both themes; inline gradient since it's a one-off scrim. @@ -59,13 +63,13 @@ export function FeedCardGlassActions({ coverScrim = false, }: ActionButtonsProps & { coverScrim?: boolean }): ReactElement | null { const isFeedPreview = useFeedPreviewMode(); + const { getUpvoteAnimation } = useBrandSponsorship(); const { isUpvoteActive, isDownvoteActive, onToggleUpvote, onToggleDownvote, onToggleBookmark, - onCopyLink, } = useCardActions({ post, onUpvoteClick, @@ -74,6 +78,24 @@ export function FeedCardGlassActions({ onCopyLinkClick, }); + // Branded upvote animation (icon swaps to the advertiser logo) when the post + // has a sponsored tag (engagement ad). + const brandAnimation = useMemo(() => { + const animationResult = getUpvoteAnimation(post.tags || []); + if ( + !animationResult.shouldAnimate || + !animationResult.colors || + !animationResult.config + ) { + return null; + } + return { + colors: animationResult.colors, + config: animationResult.config, + brandLogo: animationResult.brandLogo, + }; + }, [getUpvoteAnimation, post.tags]); + if (isFeedPreview) { return null; } @@ -110,6 +132,7 @@ export function FeedCardGlassActions({ } > @@ -193,11 +216,20 @@ export function FeedCardGlassActions({
+ {/* DEMO: static copy button. Does a plain synchronous clipboard + write with no interaction tracking or async state, so it can + never render a loading spinner in the action pill. */} } - onClick={onCopyLink} + onClick={() => { + if (typeof navigator !== 'undefined') { + navigator.clipboard?.writeText( + post.commentsPermalink || post.permalink || '', + ); + } + }} variant={ButtonVariant.Tertiary} color={ButtonColor.Cabbage} className="pointer-events-auto" diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index 11783726f2f..974558896ec 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -1,11 +1,12 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { UserVote } from '../../../graphql/posts'; import { useViewSize, useVotePost, ViewSize } from '../../../hooks'; import { useBookmarkPost } from '../../../hooks/useBookmarkPost'; import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; +import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; import { useCanAwardUser } from '../../../hooks/useCoresFeature'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; @@ -66,6 +67,25 @@ export const FocusCardActionBar = ({ sendingUser: user, receivingUser: post.author as LoggedUser | undefined, }); + const { getUpvoteAnimation } = useBrandSponsorship(); + + // Branded upvote animation (icon swaps to the advertiser logo) when the post + // has a sponsored tag (engagement ad) — same as the feed card. + const brandAnimation = useMemo(() => { + const animationResult = getUpvoteAnimation(post.tags || []); + if ( + !animationResult.shouldAnimate || + !animationResult.colors || + !animationResult.config + ) { + return null; + } + return { + colors: animationResult.colors, + config: animationResult.config, + brandLogo: animationResult.brandLogo, + }; + }, [getUpvoteAnimation, post.tags]); // Track whether the bar is pinned, and at which edge. The sentinel sits just // above the bar: when it scrolls above the viewport top the bar is pinned at @@ -233,8 +253,10 @@ export const FocusCardActionBar = ({ id="upvote-post-btn" label="Upvote" color={ButtonColor.Avocado} - icon={} - iconPressed={} + icon={} + iconPressed={ + + } count={isPinned ? upvotes : undefined} pressed={isUpvoteActive} onClick={onToggleUpvote} diff --git a/packages/shared/src/components/tags/TagTopicPage.tsx b/packages/shared/src/components/tags/TagTopicPage.tsx index 74938355a8f..f0562beb19f 100644 --- a/packages/shared/src/components/tags/TagTopicPage.tsx +++ b/packages/shared/src/components/tags/TagTopicPage.tsx @@ -57,7 +57,9 @@ import EntityCardSkeleton from '../cards/entity/EntityCardSkeleton'; import { TagPageNavbar } from './TagPageNavbar'; import { PublicPageSignupBanner } from '../auth/PublicPageSignupBanner'; import { largeNumberFormat } from '../../lib/numberFormat'; -import { webappUrl } from '../../lib/constants'; +import { isTesting, webappUrl } from '../../lib/constants'; +import { GoogleCloudStrip } from '../../features/googleCloudTakeover/GoogleCloudStrip'; +import { googleCloudTakeoverEnabled } from '../../features/googleCloudTakeover/config'; import { Typography, TypographyColor, @@ -461,6 +463,14 @@ export const TagTopicPage = ({
+ {/* DEMO: the tag page's post feed ("All posts about …") is the last + section, so the in-feed takeover lands far below the fold. Surface + the Google Cloud placement prominently at the top of the tag page + too, so it's visible without scrolling. */} + {googleCloudTakeoverEnabled && !isTesting && ( + + )} + {showRoadmap && initialData?.flags?.roadmap && (
Roadmaps diff --git a/packages/shared/src/contexts/EngagementAdsContext.tsx b/packages/shared/src/contexts/EngagementAdsContext.tsx index 1be5807c5e3..aa69bf0ffde 100644 --- a/packages/shared/src/contexts/EngagementAdsContext.tsx +++ b/packages/shared/src/contexts/EngagementAdsContext.tsx @@ -12,7 +12,8 @@ import { } from '../lib/engagementAds'; import { useIsLightTheme } from '../hooks/utils/useThemedAsset'; import { useAuthContext } from './AuthContext'; -import { isProduction } from '../lib/constants'; +import { isProduction, isTesting } from '../lib/constants'; +import { googleCloudTakeoverEnabled } from '../features/googleCloudTakeover/config'; interface EngagementAdsContextValue { /** All creatives from boot, theme-resolved */ @@ -31,7 +32,7 @@ const defaultValue: EngagementAdsContextValue = { getCreativeForTool: () => null, }; -const EngagementAdsContext = +export const EngagementAdsContext = createContext(defaultValue); export const useEngagementAdsContext = (): EngagementAdsContextValue => @@ -50,6 +51,14 @@ export const EngagementAdsProvider = ({ const { user } = useAuthContext(); const resolvedCreatives = useMemo(() => { + // DEMO: during the Google Cloud takeover, suppress the app-wide engagement + // creatives so real feed posts don't animate / fetch a brand logo on upvote + // (that's the only Google Cloud engagement ad in the demo, and it's scoped + // to its own provider on the engagement card). + if (googleCloudTakeoverEnabled && !isTesting) { + return []; + } + if (isProduction && user?.isPlus) { return []; } diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudAnnouncementBar.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudAnnouncementBar.tsx new file mode 100644 index 00000000000..b2988d7bf2a --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudAnnouncementBar.tsx @@ -0,0 +1,79 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../components/typography/Typography'; +import CloseButton from '../../components/CloseButton'; +import { ButtonSize } from '../../components/buttons/Button'; +import { GoogleCloudLogo } from './GoogleCloudLogo'; +import { GoogleCloudCta } from './GoogleCloudCta'; +import { googleCloudMessage } from './content'; +import { gcpHairline, gcpSurfaceBg } from './brand'; + +type GoogleCloudAnnouncementBarProps = { + className?: string; +}; + +// Compact, single-row product announcement bar: Google Cloud logo + inline +// message, brand CTA, dismissible via the shared CloseButton. +export const GoogleCloudAnnouncementBar = ({ + className, +}: GoogleCloudAnnouncementBarProps): ReactElement | null => { + const [dismissed, setDismissed] = useState(false); + + if (dismissed) { + return null; + } + + const { title, barBody, cta, ctaUrl } = googleCloudMessage; + + return ( +
+
+
+ +
+
+ + {title} + + + {barBody} + +
+ + {cta} + +
+ setDismissed(true)} + /> +
+ ); +}; diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudBlogCard.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudBlogCard.tsx new file mode 100644 index 00000000000..0a0381d4abf --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudBlogCard.tsx @@ -0,0 +1,94 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { ArticleGrid } from '../../components/cards/article/ArticleGrid'; +import { ArticleList } from '../../components/cards/article/ArticleList'; +import ArticlePostModal from '../../components/modals/ArticlePostModal'; +import { PostPosition } from '../../hooks/usePostModalNavigation'; +import type { Post } from '../../graphql/posts'; +import { UserVote } from '../../graphql/posts'; +import { googleCloudBlogPost } from './content'; +import { seedGoogleCloudDiscussion } from './fakeDiscussion'; + +type GoogleCloudBlogCardProps = { + isList?: boolean; + className?: string; +}; + +const noop = () => undefined; + +const openBlog = () => { + if (typeof window !== 'undefined') { + window.open(googleCloudBlogPost.permalink, '_blank', 'noopener,noreferrer'); + } +}; + +// The sponsored Google Cloud blog post. Renders the real ArticleGrid/ArticleList +// so it's identical to an organic card and behaves like a normal post: +// - clicking opens the standard article post modal (with a simulated 48-comment +// discussion seeded for the demo), and "Read post" redirects to the blog; +// - upvote / downvote / bookmark toggle locally so engagement is demo-able. +export const GoogleCloudBlogCard = ({ + isList = false, + className, +}: GoogleCloudBlogCardProps): ReactElement => { + const queryClient = useQueryClient(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [post, setPost] = useState(googleCloudBlogPost); + const Card = isList ? ArticleList : ArticleGrid; + + // Seed the simulated discussion so the post modal shows engagement. + useEffect(() => { + seedGoogleCloudDiscussion(queryClient, googleCloudBlogPost.id); + }, [queryClient]); + + const toggleVote = (vote: UserVote) => + setPost((current) => { + const isActive = current.userState?.vote === vote; + const wasUpvote = current.userState?.vote === UserVote.Up; + const willUpvote = !isActive && vote === UserVote.Up; + const upvoteDelta = (willUpvote ? 1 : 0) - (wasUpvote ? 1 : 0); + return { + ...current, + numUpvotes: Math.max(0, (current.numUpvotes ?? 0) + upvoteDelta), + userState: { + ...current.userState, + vote: isActive ? UserVote.None : vote, + }, + }; + }); + + const toggleBookmark = () => + setPost((current) => ({ ...current, bookmarked: !current.bookmarked })); + + return ( + <> + setIsModalOpen(true)} + onPostAuxClick={openBlog} + onUpvoteClick={() => toggleVote(UserVote.Up)} + onDownvoteClick={() => toggleVote(UserVote.Down)} + onCommentClick={() => setIsModalOpen(true)} + onBookmarkClick={toggleBookmark} + onShare={noop} + onCopyLinkClick={noop} + onReadArticleClick={openBlog} + onMenuClick={noop} + openNewTab + domProps={{ className }} + /> + {isModalOpen && ( + setIsModalOpen(false)} + /> + )} + + ); +}; diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudCta.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudCta.tsx new file mode 100644 index 00000000000..32297c94620 --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudCta.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { gcpButtonStyle, gcpProductBlue } from './brand'; + +type GoogleCloudCtaProps = { + href: string; + children: React.ReactNode; + className?: string; + // White fill with blue label — for use on the blue strip background where + // the solid blue button would have no contrast. + inverted?: boolean; +}; + +// Google product-blue CTA. Uses an inline style for the brand fill (outside +// the design-system palette) and design-system spacing/typography classes +// for everything else. +export const GoogleCloudCta = ({ + href, + children, + className, + inverted = false, +}: GoogleCloudCtaProps): ReactElement => ( + + {children} + +); diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudEngagementCard.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudEngagementCard.tsx new file mode 100644 index 00000000000..7531504000e --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudEngagementCard.tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { ArticleGrid } from '../../components/cards/article/ArticleGrid'; +import { ArticleList } from '../../components/cards/article/ArticleList'; +import ArticlePostModal from '../../components/modals/ArticlePostModal'; +import { PostPosition } from '../../hooks/usePostModalNavigation'; +import type { Post } from '../../graphql/posts'; +import { UserVote } from '../../graphql/posts'; +import { GoogleCloudEngagementProvider } from './GoogleCloudEngagementProvider'; +import { googleCloudEngagementPost } from './engagementContent'; +import { seedGoogleCloudEngagementDiscussion } from './fakeDiscussion'; + +type GoogleCloudEngagementCardProps = { + isList?: boolean; + className?: string; +}; + +const noop = () => undefined; + +const openPost = () => { + if (typeof window !== 'undefined') { + window.open( + googleCloudEngagementPost.permalink, + '_blank', + 'noopener,noreferrer', + ); + } +}; + +// The second feed card: a hardcoded popular post that Google Cloud "promotes +// engagement" on. Wrapped in a scoped EngagementAdsProvider so the real brand +// system fires here only — upvoting plays the Google Cloud icon animation, and +// opening the post highlights the sponsored tag. Otherwise it behaves like a +// normal organic card (opens the post modal, has a simulated discussion). +export const GoogleCloudEngagementCard = ({ + isList = false, + className, +}: GoogleCloudEngagementCardProps): ReactElement => { + const queryClient = useQueryClient(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [post, setPost] = useState(googleCloudEngagementPost); + const Card = isList ? ArticleList : ArticleGrid; + + useEffect(() => { + seedGoogleCloudEngagementDiscussion( + queryClient, + googleCloudEngagementPost.id, + ); + }, [queryClient]); + + const toggleVote = (vote: UserVote) => + setPost((current) => { + const isActive = current.userState?.vote === vote; + const wasUpvote = current.userState?.vote === UserVote.Up; + const willUpvote = !isActive && vote === UserVote.Up; + const upvoteDelta = (willUpvote ? 1 : 0) - (wasUpvote ? 1 : 0); + return { + ...current, + numUpvotes: Math.max(0, (current.numUpvotes ?? 0) + upvoteDelta), + userState: { + ...current.userState, + vote: isActive ? UserVote.None : vote, + }, + }; + }); + + const toggleBookmark = () => + setPost((current) => ({ ...current, bookmarked: !current.bookmarked })); + + return ( + + setIsModalOpen(true)} + onPostAuxClick={openPost} + onUpvoteClick={() => toggleVote(UserVote.Up)} + onDownvoteClick={() => toggleVote(UserVote.Down)} + onCommentClick={() => setIsModalOpen(true)} + onBookmarkClick={toggleBookmark} + onShare={noop} + onCopyLinkClick={noop} + onReadArticleClick={openPost} + onMenuClick={noop} + openNewTab + domProps={{ className }} + /> + {isModalOpen && ( + setIsModalOpen(false)} + /> + )} + + ); +}; diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudEngagementProvider.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudEngagementProvider.tsx new file mode 100644 index 00000000000..65f5f88814a --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudEngagementProvider.tsx @@ -0,0 +1,42 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import { EngagementAdsContext } from '../../contexts/EngagementAdsContext'; +import { + findCreativeForTags, + findCreativeForTool, + parseCreatives, + resolveCreative, +} from '../../lib/engagementAds'; +import { useIsLightTheme } from '../../hooks/utils/useThemedAsset'; +import { googleCloudEngagementCreativeRaw } from './engagementContent'; + +// Scoped engagement-ads provider for the demo. Supplies just the Google Cloud +// creative to its subtree, so the branded upvote animation and sponsored tag +// fire ONLY on the card it wraps. Unlike the app-level provider, it doesn't +// drop creatives for Plus users — the sales demo must render on any account. +export const GoogleCloudEngagementProvider = ({ + children, +}: { + children: ReactNode; +}): ReactElement => { + const isLight = useIsLightTheme(); + + const value = useMemo(() => { + const creatives = parseCreatives([googleCloudEngagementCreativeRaw]).map( + (creative) => resolveCreative(creative, isLight), + ); + return { + creatives, + getCreativeForTags: (tags: string[]) => + findCreativeForTags(creatives, tags), + getCreativeForTool: (toolName?: string | null) => + findCreativeForTool(creatives, toolName), + }; + }, [isLight]); + + return ( + + {children} + + ); +}; diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudHeadAd.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudHeadAd.tsx new file mode 100644 index 00000000000..dda6ab5ee0d --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudHeadAd.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Card, + CardImage, + CardSpace, + CardTextContainer, + CardTitle, +} from '../../components/cards/common/Card'; +import AdLink from '../../components/cards/ad/common/AdLink'; +import { AdImage } from '../../components/cards/ad/common/AdImage'; +import { AdPixel } from '../../components/cards/ad/common/AdPixel'; +import { AdFavicon } from '../../components/cards/ad/common/AdFavicon'; +import { AdList } from '../../components/cards/ad/AdList'; +import PostTags from '../../components/cards/common/PostTags'; +import { ActiveFeedContext } from '../../contexts/ActiveFeedContext'; +import { googleCloudAd } from './content'; + +type GoogleCloudHeadAdProps = { + isList?: boolean; + className?: string; +}; + +const noop = () => undefined; +const adFeedContext = { items: [], queryKey: ['gcp-takeover-ad'] }; + +// The Google Cloud ad slot. Built from the real ad sub-components so it reads +// like a production ad card, with takeover tweaks: the only attribution is +// "Promoted", styled to match the date / read-time metadata of organic post +// cards (no CTA button / "Advertise here" / "Remove"). +// On list/mobile layout there's no hover, so fall back to the standard AdList. +export const GoogleCloudHeadAd = ({ + isList = false, + className, +}: GoogleCloudHeadAdProps): ReactElement => { + if (isList) { + return ( + + + + ); + } + + return ( + + + + + + + {googleCloudAd.description} + + + {/* Advertiser cards carry tag chips like organic cards (mirrors the + real AdGrid's PostTags row). */} + {!!googleCloudAd.matchingTags?.length && ( + + )} + {/* Match the exact look of a post card's date / read-time line. + No horizontal margin — CardTextContainer already applies mx-4, so + this lines up flush-left with the title above it. */} +
+ Promoted +
+
+ + +
+
+ ); +}; diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudLogo.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudLogo.tsx new file mode 100644 index 00000000000..c51a15f90d0 --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudLogo.tsx @@ -0,0 +1,56 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +// Official Google Cloud logo (four-color cloud mark). Colors are SVG `fill` +// attributes, not Tailwind classNames, so they sit outside the +// `no-custom-color` rule. +const PATHS: ReadonlyArray<{ d: string; fill: string }> = [ + { + d: 'M40.728 20.488l2.05.035 5.57-5.57.27-2.36C44.2 8.657 38.367 6.26 31.993 6.26c-11.54 0-21.28 7.852-24.163 18.488.608-.424 1.908-.106 1.908-.106l11.13-1.83s.572-.947.862-.9A13.88 13.88 0 0 1 32 17.375c3.3.007 6.34 1.173 8.728 3.102z', + fill: '#ea4335', + }, + { + d: 'M56.17 24.77c-1.293-4.77-3.958-8.982-7.555-12.177l-7.887 7.887c3.16 2.55 5.187 6.452 5.187 10.82v1.392c3.837 0 6.954 3.124 6.954 6.954 0 3.837-3.124 6.954-6.954 6.954H32.007L30.615 48v8.346l1.392 1.385h13.908A18.11 18.11 0 0 0 64 39.647c-.007-6.155-3.1-11.6-7.83-14.876z', + fill: '#4285f4', + }, + { + d: 'M18.085 57.74h13.9V46.6h-13.9a6.89 6.89 0 0 1-2.862-.622l-2.007.615-5.57 5.57-.488 1.88a18 18 0 0 0 10.926 3.689z', + fill: '#34a853', + }, + { + d: 'M18.085 21.57A18.11 18.11 0 0 0 0 39.654c0 5.873 2.813 11.095 7.166 14.403l8.064-8.064a6.96 6.96 0 0 1-4.099-6.339c0-3.837 3.124-6.954 6.954-6.954 2.82 0 5.244 1.7 6.34 4.1l8.064-8.064c-3.307-4.353-8.53-7.166-14.403-7.166z', + fill: '#fbbc05', + }, +]; + +type GoogleCloudLogoProps = { + size?: number; + className?: string; +}; + +export const GoogleCloudLogo = ({ + size = 32, + className, +}: GoogleCloudLogoProps): ReactElement => ( + + {PATHS.map((p) => ( + + ))} + +); + +// Same mark serialized as a data URI on a white square, so it reads as an +// avatar/favicon inside the circular ProfilePicture used by the post/ad cards. +const paths = PATHS.map((p) => ``).join(''); +const avatarSvg = `${paths}`; + +export const googleCloudLogoDataUri = `data:image/svg+xml,${encodeURIComponent( + avatarSvg, +)}`; diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudStrip.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudStrip.tsx new file mode 100644 index 00000000000..9775194382d --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudStrip.tsx @@ -0,0 +1,43 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { GoogleCloudLogo } from './GoogleCloudLogo'; +import { GoogleCloudCta } from './GoogleCloudCta'; +import { googleCloudMessage } from './content'; +import { gcpStripBg } from './brand'; + +type GoogleCloudStripProps = { + className?: string; + style?: React.CSSProperties; +}; + +// In-feed strip, reskinned to Google Cloud. Matches the size, position, and +// structure of the production briefing strip (`BriefBanner`): a full-row, +// centered card with title → body → CTA. Reuses the shared announcement copy. +export const GoogleCloudStrip = ({ + className, + style, +}: GoogleCloudStripProps): ReactElement => { + const { title, body, cta, ctaUrl } = googleCloudMessage; + + return ( +
+
+ +
+
+

{title}

+

{body}

+
+ + {cta} + +
+ ); +}; diff --git a/packages/shared/src/features/googleCloudTakeover/GoogleCloudTakeover.tsx b/packages/shared/src/features/googleCloudTakeover/GoogleCloudTakeover.tsx new file mode 100644 index 00000000000..f9b3f573714 --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/GoogleCloudTakeover.tsx @@ -0,0 +1,115 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { GoogleCloudAnnouncementBar } from './GoogleCloudAnnouncementBar'; +import { GoogleCloudHeadAd } from './GoogleCloudHeadAd'; +import { GoogleCloudBlogCard } from './GoogleCloudBlogCard'; +import { GoogleCloudStrip } from './GoogleCloudStrip'; +import type { MockPost } from './MockFeedCard'; +import { MockFeedCard } from './MockFeedCard'; + +const topPosts: MockPost[] = [ + { + source: 'The Pragmatic Engineer', + title: + 'How staff engineers actually spend their time (and why it surprised us)', + tag: '#career', + meta: 'Jun 21 · 9 min read', + avatar: 'bg-accent-cabbage-default', + cover: + 'bg-gradient-to-br from-accent-blueCheese-default to-accent-cabbage-default', + upvotes: '1.4K', + comments: '212', + }, + { + source: 'CSS-Tricks', + title: + 'Container queries are finally everywhere: the mental model that clicks', + tag: '#webdev', + meta: 'Jun 21 · 7 min read', + avatar: 'bg-accent-bun-default', + cover: 'bg-gradient-to-br from-accent-bun-default to-accent-cheese-default', + upvotes: '983', + comments: '64', + }, + { + source: 'GitHub Blog', + title: 'Shipping faster with merge queues: what we learned at scale', + tag: '#devops', + meta: 'Jun 20 · 5 min read', + avatar: 'bg-accent-water-default', + cover: + 'bg-gradient-to-br from-accent-water-default to-accent-cabbage-default', + upvotes: '1.1K', + comments: '88', + }, + { + source: 'OpenAI', + title: 'Designing reliable agent loops: retries, guards, and evals', + tag: '#ai', + meta: 'Jun 19 · 10 min read', + avatar: 'bg-accent-cheese-default', + cover: + 'bg-gradient-to-br from-accent-cheese-default to-accent-ketchup-default', + upvotes: '3.2K', + comments: '417', + }, +]; + +const bottomPosts: MockPost[] = [ + { + source: 'Rust Blog', + title: 'Async traits land in stable Rust: what changes for library authors', + tag: '#rust', + meta: 'Jun 20 · 11 min read', + avatar: 'bg-accent-ketchup-default', + cover: + 'bg-gradient-to-br from-accent-ketchup-default to-accent-bun-default', + upvotes: '2.1K', + comments: '348', + }, + { + source: 'Vercel', + title: 'Edge rendering at scale: lessons from a year of streaming React', + tag: '#nextjs', + meta: 'Jun 20 · 6 min read', + avatar: 'bg-accent-onion-default', + cover: + 'bg-gradient-to-br from-accent-onion-default to-accent-blueCheese-default', + upvotes: '1.7K', + comments: '129', + }, + { + source: 'Smashing Magazine', + title: 'Designing accessible color systems without a contrast spreadsheet', + tag: '#design', + meta: 'Jun 19 · 8 min read', + avatar: 'bg-accent-lettuce-default', + cover: + 'bg-gradient-to-br from-accent-lettuce-default to-accent-water-default', + upvotes: '742', + comments: '51', + }, +]; + +// The full Google Cloud advertiser takeover laid out as a first-time-user +// feed: announcement bar on top, then a feed grid whose first two cards are +// the sponsored blog card and the head ad slot, with the branded strip +// breaking the feed in the middle. +export const GoogleCloudTakeover = (): ReactElement => ( +
+ +
+ + {topPosts.map((post, index) => ( + + + {index === 0 && } + + ))} + + {bottomPosts.map((post) => ( + + ))} +
+
+); diff --git a/packages/shared/src/features/googleCloudTakeover/MockFeedCard.tsx b/packages/shared/src/features/googleCloudTakeover/MockFeedCard.tsx new file mode 100644 index 00000000000..1320122c847 --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/MockFeedCard.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { + BookmarkIcon, + DiscussIcon, + MenuIcon, + UpvoteIcon, +} from '../../components/icons'; + +export type MockPost = { + source: string; + title: string; + tag: string; + meta: string; + avatar: string; + cover: string; + upvotes: string; + comments: string; +}; + +type MockFeedCardProps = { + post: MockPost; + className?: string; +}; + +// A static, dependency-free post card that fills the feed around the four +// Google Cloud placements so the takeover reads in real feed context. +// Mirrors the production `ArticleGrid` chrome (source row → title → tag → +// meta → cover → engagement bar) without the feed-internal hooks. +export const MockFeedCard = ({ + post, + className, +}: MockFeedCardProps): ReactElement => ( +
+
+
+ + {post.source} + +
+

+ {post.title} +

+

+ {post.tag} +

+

+ {post.meta} +

+
+
+
+
+
+
+ +
+
+
+); diff --git a/packages/shared/src/features/googleCloudTakeover/brand.ts b/packages/shared/src/features/googleCloudTakeover/brand.ts new file mode 100644 index 00000000000..a2d66ec9cf9 --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/brand.ts @@ -0,0 +1,28 @@ +// Google Cloud brand values for the advertiser-takeover demo. +// Applied via inline `style` (like `briefButtonBg` in shared/styles/custom.ts) +// so they bypass the `no-custom-color` lint rule, which only inspects +// `bg-`/`text-` Tailwind classNames — not inline styles or SVG fills. + +export const gcpBlue = '#4285F4'; +export const gcpRed = '#EA4335'; +export const gcpYellow = '#FBBC04'; +export const gcpGreen = '#34A853'; +export const gcpProductBlue = '#1A73E8'; + +// Subtle four-color wash over the app's subtle surface — for the +// announcement bar and the head ad slot. +export const gcpSurfaceBg = + 'linear-gradient(90deg, rgba(66,133,244,0.16) 0%, rgba(234,67,53,0.10) 34%, rgba(251,188,4,0.10) 67%, rgba(52,168,83,0.16) 100%), var(--theme-background-subtle)'; + +// Deep-blue gradient for the in-feed strip — dark enough to carry white +// text at AA for the large/bold sizes used there. +export const gcpStripBg = + 'linear-gradient(270deg, #1A73E8 0%, #4285F4 50%, #174EA6 100%)'; + +export const gcpHairline = '1px solid rgba(66,133,244,0.32)'; + +// Solid Google product-blue CTA, white label. +export const gcpButtonStyle = { + backgroundColor: gcpProductBlue, + color: '#ffffff', +} as const; diff --git a/packages/shared/src/features/googleCloudTakeover/config.ts b/packages/shared/src/features/googleCloudTakeover/config.ts new file mode 100644 index 00000000000..65c1643b7c0 --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/config.ts @@ -0,0 +1,19 @@ +// DEMO TOGGLE — Google Cloud advertiser takeover injected directly into the +// main feed (My Feed / Popular). When enabled, the four GCP placements render +// for ALL users with no flag/auth/Plus gating, so the takeover can be shown +// live on any account. +// +// NOTE: this is a hardcoded sales-demo switch, not a GrowthBook experiment. +// It is `true` on purpose for the demo. Set it to `false` to turn the takeover +// off, and do not ship it enabled to a production audience. +export const googleCloudTakeoverEnabled = true; + +// Number of cards prepended at the top of the feed before the real items: the +// sponsored blog card + the engagement (second) card. The head ad is injected +// separately before item 0. Used to keep the strip aligned to a row boundary. +export const googleCloudPrependedCards = 2; + +// The full-row strip starts at this grid row (0-based). Picking a whole row +// (rather than an item index) guarantees the row above it is full, so there +// are no empty cells before the strip. +export const googleCloudStripRow = 4; diff --git a/packages/shared/src/features/googleCloudTakeover/content.ts b/packages/shared/src/features/googleCloudTakeover/content.ts new file mode 100644 index 00000000000..672d047790a --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/content.ts @@ -0,0 +1,76 @@ +// Hardcoded content for the Google Cloud advertiser-takeover demo. +// The blog post is the most recent post on the Google Cloud blog +// (sourced 2026-06-20). The shared message copy is reused by both the +// announcement bar and the in-feed strip. + +import type { Ad, Post } from '../../graphql/posts'; +import { PostType } from '../../graphql/posts'; +import { googleCloudLogoDataUri } from './GoogleCloudLogo'; +import { hoursAgo } from './relativeTime'; +import { googleCloudDiscussionCount } from './fakeDiscussion'; + +const googleCloudBlogUrl = + 'https://cloud.google.com/blog/topics/inside-google-cloud/whats-new-google-cloud'; +const googleCloudBlogImage = + 'https://storage.googleapis.com/gweb-cloudblog-publish/images/whats_new_2026_CfhxFWX.max-2500x2500.jpg'; +// A different Google Cloud blog cover for the ad slot, so it doesn't repeat +// the sponsored blog card's image. +const googleCloudAdImage = + 'https://storage.googleapis.com/gweb-cloudblog-publish/images/1148-GC-IO-Header-GC-43-0519.max-2500x2500.jpg'; + +// Rendered through the real ArticleGrid/ArticleList so the sponsored post +// looks identical to an organic feed card. The Google Cloud logo is supplied +// as the source avatar via a data URI. +export const googleCloudBlogPost: Post = { + id: 'gcp-blog-demo', + title: "What's new with Google Cloud", + summary: + 'A roundup of the latest launches, updates, and resources from Google Cloud: agentic AI, Gemini Enterprise, Spot VM optimization, and more.', + permalink: googleCloudBlogUrl, + commentsPermalink: googleCloudBlogUrl, + createdAt: hoursAgo(5), + readTime: 6, + image: googleCloudBlogImage, + domain: 'cloud.google.com', + source: { + id: 'google-cloud-blog', + handle: 'google-cloud-blog', + name: 'Google Cloud Blog', + permalink: 'https://cloud.google.com/blog', + image: googleCloudLogoDataUri, + } as unknown as Post['source'], + tags: ['cloud', 'ai', 'devops'], + numUpvotes: 312, + numComments: googleCloudDiscussionCount, + numAwards: 0, + // Share (not Article) so it is NOT reader-gated: Google blocks its blog from + // loading in the in-app reader's embed proxy (returns a 403), so "Read post" + // opens the real URL in a new tab instead. See GoogleCloudBlogCard. + type: PostType.Share, +}; + +// Rendered through the real AdGrid/AdList so it matches the live ad slot. +// `companyLogo` drives the favicon; `image` drives the cover. +export const googleCloudAd: Ad = { + company: 'Google Cloud', + description: 'Code more, config less. 👩‍💻 Deploy in seconds.', + link: 'https://cloud.google.com/free', + source: 'Google Cloud', + image: googleCloudAdImage, + companyLogo: googleCloudLogoDataUri, + callToAction: 'Start building free', + // Advertiser cards carry tags like organic cards; these drive the chips on + // the ad card (and the AdList/list path via `matchingTags`). + matchingTags: ['cloud', 'ai', 'devops', 'kubernetes', 'serverless', 'gemini'], +}; + +// Shared messaging for the announcement bar + in-feed strip. The bar uses a +// short body so the centered logo/text/CTA group stays compact and the CTA +// sits centrally, clear of the close button. +export const googleCloudMessage = { + title: 'Google Cloud supports developers', + barBody: 'Get $300 in free credits, on us.', + body: 'Get $300 in free credits to build, test, and ship your next project on Google Cloud, on us.', + cta: 'Claim credits', + ctaUrl: 'https://cloud.google.com/free', +}; diff --git a/packages/shared/src/features/googleCloudTakeover/engagementContent.ts b/packages/shared/src/features/googleCloudTakeover/engagementContent.ts new file mode 100644 index 00000000000..a434f807ae1 --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/engagementContent.ts @@ -0,0 +1,71 @@ +// Content for the second feed card in the Google Cloud takeover: a hardcoded +// popular explore-style post that Google Cloud "promotes engagement" on via the +// real Engagement Ads system (branded upvote animation + sponsored tag). + +import type { Post } from '../../graphql/posts'; +import { PostType } from '../../graphql/posts'; +import { googleCloudLogoDataUri } from './GoogleCloudLogo'; +import { hoursAgo } from './relativeTime'; +import { googleCloudEngagementDiscussionCount } from './fakeDiscussion'; + +const engagementPostUrl = + 'https://huggingface.co/blog/building-production-ai-agents'; +const engagementPostImage = + 'https://images.unsplash.com/photo-1620712943543-bcc4688e7485?auto=format&fit=crop&w=1080&q=80'; + +// The tag Google Cloud sponsors. Must appear in the post's tags so the +// engagement system can match and brand it. +export const googleCloudSponsoredTag = 'ai'; + +// A realistic, popular organic post (Share type renders identically to an +// article in ArticleGrid/ArticlePostModal but isn't reader-gated, so "Read +// post" links straight to the source instead of opening the in-app reader). +export const googleCloudEngagementPost: Post = { + id: 'gcp-engagement-post', + title: 'Building production-ready AI agents: lessons from a year in prod', + summary: + 'What actually breaks when you take an AI agent from a demo to real traffic: tool calling, evals, cost control, and the guardrails we wish we had on day one.', + permalink: engagementPostUrl, + commentsPermalink: engagementPostUrl, + createdAt: hoursAgo(28), + readTime: 9, + image: engagementPostImage, + source: { + id: 'huggingface', + handle: 'huggingface', + name: 'Hugging Face', + permalink: 'https://huggingface.co/blog', + image: 'https://github.com/huggingface.png', + } as unknown as Post['source'], + tags: [googleCloudSponsoredTag, 'machine-learning', 'llm', 'python'], + numUpvotes: 1843, + numComments: googleCloudEngagementDiscussionCount, + numAwards: 0, + type: PostType.Share, +}; + +// Raw Engagement Ads creative (snake_case, the boot API shape) for Google +// Cloud. Parsed/resolved by the scoped provider. Matched to the post above by +// the `tags` overlap, which drives both the branded tag and the upvote icon +// swap. +export const googleCloudEngagementCreativeRaw = { + gen_id: 'gcp-engagement-demo', + promoted_name: 'Google Cloud', + promoted_body: + 'Build, deploy, and scale AI agents on Google Cloud with Vertex AI and Gemini. Get $300 in free credits to start.', + promoted_cta: 'Start building free', + promoted_url: 'https://cloud.google.com/free', + promoted_logo_img: { + dark: googleCloudLogoDataUri, + light: googleCloudLogoDataUri, + }, + promoted_icon_img: { + dark: googleCloudLogoDataUri, + light: googleCloudLogoDataUri, + }, + promoted_gradient_start: { dark: '#4285F4', light: '#4285F4' }, + promoted_gradient_end: { dark: '#34A853', light: '#34A853' }, + tools: [], + keywords: [], + tags: [googleCloudSponsoredTag], +}; diff --git a/packages/shared/src/features/googleCloudTakeover/fakeDiscussion.ts b/packages/shared/src/features/googleCloudTakeover/fakeDiscussion.ts new file mode 100644 index 00000000000..a8fc9c86e4b --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/fakeDiscussion.ts @@ -0,0 +1,546 @@ +import type { QueryClient } from '@tanstack/react-query'; +import type { Author, Comment, PostCommentsData } from '../../graphql/comments'; +import { generateCommentsQueryKey, getAllCommentsQuery } from '../../lib/query'; +import { hoursAgo } from './relativeTime'; + +// A simulated discussion for the sponsored Google Cloud blog post, written to +// feel like a real community thread: different voices and lengths, code +// snippets, an embedded chart, questions with replies, jokes, skeptics, war +// stories. Seeded straight into the comments query cache (and pinned so a +// refetch can't clear it) since the post isn't a real backend post. + +// Real avatar photos (deterministic per index) so the discussion doesn't look +// like a placeholder. Verified company logos use GitHub org avatars, which +// redirect to the real image and load reliably. +const avatar = (img: number): string => `https://i.pravatar.cc/150?img=${img}`; +const orgLogo = (org: string): string => `https://github.com/${org}.png`; + +type Person = { + name: string; + username: string; + img: number; + reputation: number; + company?: { name: string; org: string }; +}; + +const people: Person[] = [ + { + name: 'Priya Sharma', + username: 'priyabuilds', + img: 5, + reputation: 18430, + company: { name: 'Vercel', org: 'vercel' }, + }, + { name: 'Marcus Lee', username: 'marcusdev', img: 12, reputation: 9120 }, + { + name: 'Sofia Alvarez', + username: 'sofiacodes', + img: 47, + reputation: 31280, + company: { name: 'Stripe', org: 'stripe' }, + }, + { name: 'Tom Becker', username: 'tbecker', img: 33, reputation: 2740 }, + { + name: 'Aisha Khan', + username: 'aishak', + img: 23, + reputation: 12660, + company: { name: 'Microsoft', org: 'microsoft' }, + }, + { name: 'Daniel Park', username: 'dpark', img: 8, reputation: 5380 }, + { + name: 'Lena Novak', + username: 'lenan', + img: 16, + reputation: 44910, + company: { name: 'GitHub', org: 'github' }, + }, + { name: 'Owen Wright', username: 'owenw', img: 51, reputation: 870 }, + { + name: 'Rina Tanaka', + username: 'rinat', + img: 44, + reputation: 21540, + company: { name: 'Datadog', org: 'DataDog' }, + }, + { name: 'Caleb Stone', username: 'cstone', img: 60, reputation: 3960 }, + { name: 'Mira Patel', username: 'mirap', img: 26, reputation: 15070 }, + { name: 'Jonas Vogel', username: 'jvogel', img: 14, reputation: 6620 }, + { + name: 'Elena Rossi', + username: 'elenar', + img: 20, + reputation: 27800, + company: { name: 'Shopify', org: 'shopify' }, + }, + { name: 'Hassan Ali', username: 'hassana', img: 56, reputation: 4310 }, + { name: 'Yuki Sato', username: 'yukis', img: 65, reputation: 11200 }, + { name: "Sam O'Neill", username: 'samon', img: 40, reputation: 1580 }, +]; + +const chartImage = + 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=900&q=80'; + +type Reply = { p: number; html: string; up: number; h: number }; +type Spec = { + p: number; + html: string; + up: number; + h: number; + replies?: Reply[]; +}; + +// Curated discussion. `p` indexes `people`, `up` is upvotes, `h` is hours ago. +const specs: Spec[] = [ + { + p: 0, + up: 214, + h: 3, + html: `

The Spot VM optimizer is the headline for us. We run a nightly feature-engineering job on a 40-node pool and just flipped it to spot with a fallback to on-demand. First week came in at roughly 71% cheaper with zero failed runs thanks to the new graceful drain window.

If you have anything batch-shaped and idempotent, this is basically free money.

`, + replies: [ + { + p: 7, + up: 12, + h: 2, + html: `

How are you handling the 30s preemption notice? Checkpointing mid-job or just letting the orchestrator retry the shard?

`, + }, + { + p: 0, + up: 28, + h: 1, + html: `

Retry at the shard level. Each shard writes to a temp prefix and we only promote on success, so a preemption just re-runs that shard. Way simpler than trying to checkpoint model state.

`, + }, + ], + }, + { + p: 2, + up: 167, + h: 5, + html: `

People sleep on how much of this is just sane defaults now. Deploying a containerized agent used to be a half-day of IAM yak-shaving. Today it's basically:

gcloud run deploy support-agent \\
+  --image=us-docker.pkg.dev/$PROJECT/agents/support:latest \\
+  --region=us-central1 \\
+  --cpu=2 --memory=2Gi \\
+  --concurrency=8 --min-instances=1

Cold starts on min-instances=1 are under 400ms for us. That's the whole story.

`, + }, + { + p: 4, + up: 9, + h: 8, + html: `

Genuine question from someone newer to GCP: is Cloud Run the right call for a stateful websocket service, or should I be looking at GKE for that? The docs hint at both and I can't tell what the "blessed" path is.

`, + replies: [ + { + p: 8, + up: 41, + h: 7, + html: `

Cloud Run supports websockets fine now (60 min request timeout), but if you need sticky sessions or your own ingress rules, GKE Autopilot is less of a fight. Rule of thumb we use: stateless and bursty goes to Run, anything that wants to own its networking goes to GKE.

`, + }, + { + p: 4, + up: 3, + h: 6, + html: `

That's the clearest answer I've gotten, thank you. Going with Run for now.

`, + }, + ], + }, + { + p: 6, + up: 132, + h: 11, + html: `

Migrated a 12-service stack off self-managed k8s to Autopilot last quarter. The honest scorecard:

  • Node management toil: basically gone.
  • Bill: down about 22% after we right-sized requests (the optimizer recs were accurate).
  • Surprises: a couple of DaemonSets needed rework because you don't own the nodes anymore.

Net very positive, but budget a sprint for the DaemonSet stuff if you're coming from standard GKE.

`, + }, + { + p: 8, + up: 1, + h: 14, + html: `

+1, the docs are genuinely good now. Felt like that needed saying.

`, + }, + { + p: 3, + up: 88, + h: 9, + html: `

I'll be the skeptic. Every cloud keynote promises "agentic AI" and most of it is a thin wrapper over function calling. What's actually different here versus building the same loop yourself with the SDK?

`, + replies: [ + { + p: 10, + up: 54, + h: 8, + html: `

Fair, but the managed part is the eval + tracing story, not the loop. Getting per-step traces, replayable sessions, and a grounding layer wired into your own data without standing up infra is the actual time save. The loop was never the hard part.

`, + }, + ], + }, + { + p: 12, + up: 76, + h: 16, + html: `

The in-country processing for Gemini quietly unblocks a sovereign workload we shelved last year. For regulated EU data this is the difference between "no" and "yes" from our compliance team. Underrated line item in this post.

`, + }, + { + p: 9, + up: 121, + h: 6, + html: `

We A/B'd the new autoscaling profile on a latency-sensitive service. p95 before vs after the switch:

p95 latency dashboard, before and after the autoscaling change

Roughly a 38% drop at the tail without adding baseline cost. The scale-up reaction time is the real improvement.

`, + }, + { + p: 5, + up: 23, + h: 19, + html: `

Anyone running the serverless Spark jobs in production yet? Cold starts were the only thing stopping us from moving our ETL off a always-on Dataproc cluster, and the post claims that's fixed.

`, + replies: [ + { + p: 11, + up: 19, + h: 18, + html: `

Yes, ~3 weeks in. Cold start went from minutes to about 20s for our jobs. Not instant, but for hourly ETL it's a non-issue and we killed the standing cluster entirely.

`, + }, + ], + }, + { + p: 1, + up: 7, + h: 22, + html: `

Slightly off topic but the VS Code workbench notebooks going GA is the update I'm most selfishly happy about. Local editor, managed compute, no more babysitting a Jupyter VM.

`, + }, + { + p: 13, + up: 44, + h: 27, + html: `

Heads up for the cost-optimizer crowd: validate the recommendations against your own traffic shape before you apply them in bulk. We took the CPU downsizing suggestion on a spiky endpoint and got throttled during a campaign. Quick sanity check we run now:

# rough headroom check
+peak_rps * avg_cpu_ms / 1000 > provisioned_vcpu * 0.6

If that's false you probably have room, if it's true leave it alone.

`, + }, + { + p: 14, + up: 2, + h: 31, + html: `

Bookmarking purely for the deep-dive links at the bottom. That's where the actual engineering content lives every time.

`, + }, + { + p: 7, + up: 58, + h: 24, + html: `

The contrarian take nobody wants: this is great until you've got 200 services wired into managed everything and the lock-in is total. I love the DX, I just keep an abstraction layer over the queue and storage so a future migration isn't a company-ending project. Cheap insurance.

`, + replies: [ + { + p: 2, + up: 31, + h: 23, + html: `

This is the right amount of paranoia. We do the same for pub/sub and object storage and ignore it for everything else. Abstracting all of it is its own tax.

`, + }, + ], + }, + { + p: 11, + up: 95, + h: 13, + html: `

Reliability numbers in the post line up with what we see in our own dashboards, which is rare for a vendor blog. We've been at four nines on the managed gateway for two quarters with no manual intervention. Credit where it's due.

`, + }, + { + p: 10, + up: 16, + h: 36, + html: `

Mild gripe: the API management updates are nice but the pricing page still needs a PhD to parse. Would love a "here's what this costs at 10M requests/month" calculator that isn't three tabs deep.

`, + }, + { + p: 15, + up: 0, + h: 40, + html: `

Saving this to read properly after standup. The agentic tooling section looks like exactly what our team has been hacking together by hand.

`, + }, + { + p: 4, + up: 49, + h: 30, + html: `

For anyone wiring Gemini into a data warehouse: the native BigQuery integration means you can keep governance in one place instead of shuttling data to a separate vector store. That alone removed a whole service from our diagram.

`, + }, + { + p: 6, + up: 38, + h: 44, + html: `

Small thing that makes daily infra work less painful: the new default for graceful shutdown actually respects SIGTERM grace periods now. We used to lose in-flight requests on every deploy. Quiet quality-of-life win.

`, + }, + { + p: 12, + up: 11, + h: 52, + html: `

Shared this with our platform team channel. A few of these land directly on our Q3 roadmap, especially the data residency controls.

`, + }, + { + p: 9, + up: 64, + h: 20, + html: `

If you're comparing to the other big two: the thing that keeps us here is that the AI tooling and the data tooling are the same product surface. Less glue code than stitching a model provider to a separate warehouse. Your mileage will vary by stack.

`, + }, + { + p: 5, + up: 5, + h: 58, + html: `

Tried the new data residency controls in staging, no measurable latency hit for us in europe-west. Curious if anyone in apac is seeing different.

`, + }, + { + p: 3, + up: 73, + h: 17, + html: `

Okay, walking back some of my skepticism after actually trying the eval harness this weekend. Being able to replay a failed agent session step by step with the tool calls inline is genuinely good. That's the feature that earns the "agentic" label, not the marketing.

`, + }, + { + p: 1, + up: 27, + h: 48, + html: `

Underrated: the Spot + managed instance group combo means our CI runners are basically free now. We burst to 60 runners on spot and the queue time problem just disappeared.

`, + }, +]; + +// A separate discussion for the second (engagement) card, whose post is about +// shipping AI agents to production. Distinct voices/topics from the blog post +// thread above so the two cards don't repeat the same comments. +const engagementSpecs: Spec[] = [ + { + p: 2, + up: 188, + h: 4, + html: `

The single biggest reliability win for us was validating tool arguments before executing, then retrying with the validation error fed back to the model. Naive tool calling failed ~8% of the time; this got us under 1%.

const parsed = toolSchema.safeParse(call.args);
+if (!parsed.success) {
+  messages.push(toolError(call, parsed.error));
+  continue; // let the model correct itself
+}
`, + replies: [ + { + p: 7, + up: 14, + h: 3, + html: `

Do you cap the retries? We've seen a model loop forever re-emitting the same bad args.

`, + }, + { + p: 2, + up: 31, + h: 2, + html: `

Hard cap at 3, then bail to a human. The infinite loop only happens if you don't feed the actual error back; once it can see what was wrong it usually fixes it on the first retry.

`, + }, + ], + }, + { + p: 10, + up: 142, + h: 6, + html: `

Evals are the part nobody wants to build and the part that actually ships you to prod. We treat real agent traces like test fixtures: capture failures, freeze them, assert behavior doesn't regress. Without it you're just vibing in production.

`, + }, + { + p: 4, + up: 8, + h: 9, + html: `

Newer to this. For a support agent over our own docs, is RAG enough or do we actually need to fine-tune a model?

`, + replies: [ + { + p: 8, + up: 47, + h: 8, + html: `

Start with RAG, almost always. Fine-tuning is for style and format, not knowledge. In my experience 90% of "we need fine-tuning" is really "our retrieval is bad".

`, + }, + ], + }, + { + p: 6, + up: 97, + h: 12, + html: `

Cost control deserves its own chapter. Prompt-caching the system prompt and tool definitions cut our per-call cost by about 60%, because that block is byte-identical every turn. Track tokens per resolved task, not per call, or you'll optimize the wrong thing.

`, + }, + { + p: 3, + up: 79, + h: 8, + html: `

Contrarian take: "agentic AI" is a while loop with extra steps. What is actually new here versus a for-loop that calls functions?

`, + replies: [ + { + p: 10, + up: 58, + h: 7, + html: `

Mechanically, sure. The new part is the eval and tracing harness around the loop plus tool-call validation. The loop was never the hard bit; the production scaffolding is.

`, + }, + ], + }, + { + p: 9, + up: 113, + h: 5, + html: `

Per-step tracing changed how we debug agents. Replaying a failed run with every tool call and token inline is the difference between guessing and fixing. Our latency breakdown by step:

agent run latency breakdown by step

Turned out 70% of our wall-clock was one slow retrieval call, not the model.

`, + }, + { + p: 12, + up: 64, + h: 16, + html: `

Treat every tool the agent can call as an attack surface. We allowlist tools per session and never let the model build raw SQL or shell. Prompt injection from retrieved documents is real, and your RAG layer is the front door.

`, + }, + { + p: 5, + up: 21, + h: 19, + html: `

How is everyone handling perceived latency on multi-step agents? Users hate staring at a spinner for eight seconds.

`, + replies: [ + { + p: 11, + up: 26, + h: 18, + html: `

Stream the intermediate steps. Showing "searching docs... reading 3 results..." makes a 6s task feel fast. Same work, the wait just feels different.

`, + }, + ], + }, + { + p: 1, + up: 9, + h: 22, + html: `

Switching to strict structured outputs removed a whole class of parsing bugs. We used to regex the model's prose to pull fields out. Never again.

`, + }, + { + p: 13, + up: 54, + h: 27, + html: `

Context-window management is the unglamorous 80%. We summarize older turns and keep a rolling window. Rough guard before each call:

if (estimateTokens(messages) > LIMIT * 0.7) {
+  messages = [system, summarize(older), ...recent];
+}
`, + }, + { + p: 14, + up: 3, + h: 31, + html: `

Spent two weeks on a clever multi-agent setup, deleted it, replaced it with one good prompt and three tools. Faster, cheaper, and I sleep now.

`, + }, + { + p: 11, + up: 88, + h: 13, + html: `

For anything that writes (refunds, emails, deploys) we gate on human approval. The agent proposes, a person confirms. That gate caught exactly one very expensive mistake in week two and paid for itself.

`, + }, + { + p: 0, + up: 46, + h: 30, + html: `

Resist the urge to build a swarm of agents on day one. One agent with good tools beats five agents passing messages and hallucinating about each other's state.

`, + }, + { + p: 9, + up: 71, + h: 20, + html: `

We default to a cheaper, faster model and only escalate to the frontier one when an eval gate fails. Most steps don't need the big model. Routing on difficulty cut our cost more than any prompt tweak did.

`, + }, + { + p: 8, + up: 1, + h: 40, + html: `

This matches our last year painfully well. Wish I'd read it before, not after.

`, + }, + { + p: 15, + up: 0, + h: 44, + html: `

Saving this for the team offsite. The evals section alone is worth it.

`, + }, +]; + +const buildAuthor = (personIndex: number, key: string): Author => { + const person = people[personIndex]; + return { + id: `gcp-author-${key}`, + name: person.name, + username: person.username, + permalink: `https://app.daily.dev/${person.username}`, + image: avatar(person.img), + reputation: person.reputation, + createdAt: '2021-03-01T00:00:00.000Z', + companies: person.company + ? [ + { + id: person.company.org, + name: person.company.name, + image: orgLogo(person.company.org), + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + }, + ] + : undefined, + } as Author; +}; + +const buildComments = (specList: Spec[], idPrefix: string): Comment[] => + specList.map((spec, i): Comment => { + const children = (spec.replies ?? []).map((reply, j) => ({ + node: { + id: `${idPrefix}-${i}-r${j}`, + content: '', + contentHtml: reply.html, + contentEmbeds: [], + createdAt: hoursAgo(reply.h), + lastUpdatedAt: hoursAgo(reply.h), + permalink: 'https://cloud.google.com/blog', + numUpvotes: reply.up, + numAwards: 0, + author: buildAuthor(reply.p, `${idPrefix}-${i}-r${j}`), + children: { edges: [], pageInfo: { hasNextPage: false } }, + } as Comment, + })); + + return { + id: `${idPrefix}-${i}`, + content: '', + contentHtml: spec.html, + contentEmbeds: [], + createdAt: hoursAgo(spec.h), + lastUpdatedAt: hoursAgo(spec.h), + permalink: 'https://cloud.google.com/blog', + numUpvotes: spec.up, + numAwards: 0, + author: buildAuthor(spec.p, `${idPrefix}-${i}`), + children: { edges: children, pageInfo: { hasNextPage: false } }, + } as Comment; + }); + +// Total comments (top-level + replies) so each post's header count matches. +const countComments = (specList: Spec[]): number => + specList.reduce((sum, spec) => sum + 1 + (spec.replies?.length ?? 0), 0); + +export const googleCloudDiscussionCount = countComments(specs); +export const googleCloudEngagementDiscussionCount = + countComments(engagementSpecs); + +const buildDiscussion = ( + specList: Spec[], + idPrefix: string, +): PostCommentsData => ({ + postComments: { + edges: buildComments(specList, idPrefix).map((node) => ({ node })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, +}); + +// Seed every comments-query-key variant for the post and pin it so the live +// (empty) refetch can't replace the simulated discussion. +const seedDiscussion = ( + queryClient: QueryClient, + postId: string, + specList: Spec[], + idPrefix: string, +): void => { + const data = buildDiscussion(specList, idPrefix); + const keys = [ + generateCommentsQueryKey({ postId }), + ...getAllCommentsQuery(postId), + ]; + keys.forEach((key) => { + queryClient.setQueryDefaults(key, { + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }); + queryClient.setQueryData(key, data); + }); +}; + +// First (blog) card discussion. +export const seedGoogleCloudDiscussion = ( + queryClient: QueryClient, + postId: string, +): void => seedDiscussion(queryClient, postId, specs, 'gcp-comment'); + +// Second (engagement) card discussion — a distinct set of comments. +export const seedGoogleCloudEngagementDiscussion = ( + queryClient: QueryClient, + postId: string, +): void => + seedDiscussion(queryClient, postId, engagementSpecs, 'gcp-eng-comment'); diff --git a/packages/shared/src/features/googleCloudTakeover/relativeTime.ts b/packages/shared/src/features/googleCloudTakeover/relativeTime.ts new file mode 100644 index 00000000000..c24aa39d7fc --- /dev/null +++ b/packages/shared/src/features/googleCloudTakeover/relativeTime.ts @@ -0,0 +1,10 @@ +// Timestamps for the demo are computed relative to load time so the takeover +// keeps looking fresh indefinitely (it's a long-lived sales demo that never +// merges, so fixed dates would slowly age into "5 months ago"). + +const HOUR_MS = 60 * 60 * 1000; + +export const hoursAgo = (hours: number): string => + new Date(Date.now() - hours * HOUR_MS).toISOString(); + +export const daysAgo = (days: number): string => hoursAgo(days * 24); diff --git a/packages/webapp/pages/demo/google-cloud.tsx b/packages/webapp/pages/demo/google-cloud.tsx new file mode 100644 index 00000000000..cd274451147 --- /dev/null +++ b/packages/webapp/pages/demo/google-cloud.tsx @@ -0,0 +1,17 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { NextSeo } from 'next-seo'; +import { GoogleCloudTakeover } from '@dailydotdev/shared/src/features/googleCloudTakeover/GoogleCloudTakeover'; +import { getLayout } from '../../components/layouts/MainLayout'; + +const GoogleCloudDemoPage = (): ReactElement => ( + <> + + + +); + +GoogleCloudDemoPage.getLayout = getLayout; +GoogleCloudDemoPage.layoutProps = { screenCentered: false }; + +export default GoogleCloudDemoPage;