diff --git a/.gitignore b/.gitignore index 083f6f912fe..3cb40048775 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ figma-images/ node-compile-cache/ # Plan files -plans/*.md \ No newline at end of file +plans/*.md +.cursor/plans/ \ No newline at end of file diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index a425e9fe530..28d769c7f7e 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -42,6 +42,8 @@ import { LogExtraContextProvider } from '../contexts/LogExtraContext'; import { SquadAdList } from './cards/ad/squad/SquadAdList'; import { SquadAdGrid } from './cards/ad/squad/SquadAdGrid'; import { adLogEvent, feedHighlightsLogEvent, feedLogExtra } from '../lib/feed'; +import { findCreativeForTags } from '../lib/engagementAds'; +import { useEngagementAdsContext } from '../contexts/EngagementAdsContext'; import { useLogContext } from '../contexts/LogContext'; import { MarketingCtaVariant } from './marketingCta/common'; import { MarketingCtaBriefing } from './marketingCta/MarketingCtaBriefing'; @@ -192,6 +194,7 @@ export const withFeedLogExtraContext = ( props: FeedItemComponentProps, ): ReactElement | null => { const { item } = props; + const { creatives } = useEngagementAdsContext(); if ([FeedItemType.Ad, FeedItemType.Post].includes(item?.type)) { return ( @@ -213,6 +216,17 @@ export const withFeedLogExtraContext = ( extraData.referrer_target_type = post?.id ? TargetType.Post : undefined; + + if ( + item.type === FeedItemType.Post && + post?.tags && + creatives.length > 0 + ) { + const creative = findCreativeForTags(creatives, post.tags); + if (creative) { + extraData.gen_id = creative.genId; + } + } } if (isBoostedSquadAd(item)) { diff --git a/packages/shared/src/components/brand/BrandedTag.module.css b/packages/shared/src/components/brand/BrandedTag.module.css new file mode 100644 index 00000000000..580ba0b3e06 --- /dev/null +++ b/packages/shared/src/components/brand/BrandedTag.module.css @@ -0,0 +1,62 @@ +/* BrandedTag - Micro-interaction animations for sponsored tags */ + +.brandedTag { + cursor: pointer; + transition: transform 0.2s ease; +} + +.brandedTag:hover { + transform: scale(1.02); +} + +/* Tag content */ +.tagContent { + opacity: 1; + transform: translateY(0); +} + +/* Branded content */ +.brandedContent { + opacity: 0; + transform: scale(0.9); +} + +.brandedContent.fadeIn { + opacity: 1; + transform: scale(1); +} + +/* Brand logo pulse effect */ +.brandLogo { + animation: logoPulse 2s ease-in-out infinite; +} + +@keyframes logoPulse { + 0%, 100% { + transform: scale(1); + filter: drop-shadow(0 0 0 transparent); + } + 50% { + transform: scale(1.1); + filter: drop-shadow(0 0 4px currentColor); + } +} + +/* Brand text */ +.brandText { + white-space: nowrap; +} + +/* Animated (branded) state — continuous glow, no entrance animation */ +.animated { + animation: tagGlow 2s ease-in-out infinite; +} + +@keyframes tagGlow { + 0%, 100% { + box-shadow: 0 0 0 0 transparent; + } + 50% { + box-shadow: 0 0 8px 0 var(--brand-primary, #6e40c9); + } +} diff --git a/packages/shared/src/components/brand/BrandedTag.spec.tsx b/packages/shared/src/components/brand/BrandedTag.spec.tsx new file mode 100644 index 00000000000..5fc561f79fc --- /dev/null +++ b/packages/shared/src/components/brand/BrandedTag.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { defaultQueryClientTestingConfig } from '../../../__tests__/helpers/tanstack-query'; +import type { EngagementCreative } from '../../lib/engagementAds'; +import { EngagementAdsProvider } from '../../contexts/EngagementAdsContext'; +import { BrandedTag } from './BrandedTag'; + +const creative: EngagementCreative = { + gen_id: 'c1', + promoted_name: 'Copilot', + promoted_body: 'AI pair programming', + promoted_cta: 'Try free', + promoted_url: 'https://example.com', + promoted_logo_img: { + dark: 'https://example.com/logo-dark.png', + light: 'https://example.com/logo-light.png', + }, + promoted_icon_img: { + dark: 'https://example.com/icon-dark.png', + light: 'https://example.com/icon-light.png', + }, + promoted_gradient_start: { dark: '#000', light: '#fff' }, + promoted_gradient_end: { dark: '#111', light: '#eee' }, + tools: ['vscode'], + keywords: ['AI'], + tags: ['ai'], +}; + +let queryClient: QueryClient; + +beforeEach(() => { + queryClient = new QueryClient(defaultQueryClientTestingConfig); +}); + +const renderTag = (tag: string, creatives: EngagementCreative[] = []) => + render( + + + + + , + ); + +describe('BrandedTag', () => { + it('renders plain #tag when no creative matches', () => { + renderTag('react'); + expect(screen.getByText('#react')).toBeInTheDocument(); + expect(screen.queryByAltText('Copilot')).not.toBeInTheDocument(); + }); + + it('renders brand name and logo when the tag is sponsored', () => { + renderTag('ai', [creative]); + expect(screen.getByText('#ai - powered by Copilot')).toBeInTheDocument(); + // Don't pin the theme: either dark or light resolution is acceptable + expect(screen.getByAltText('Copilot')).toHaveAttribute( + 'src', + expect.stringMatching(/https:\/\/example\.com\/logo-(dark|light)\.png/), + ); + }); + + it('falls back to plain tag when branding is disabled', () => { + render( + + + + + , + ); + expect(screen.getByText('#ai')).toBeInTheDocument(); + expect(screen.queryByText(/powered by/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/brand/BrandedTag.tsx b/packages/shared/src/components/brand/BrandedTag.tsx new file mode 100644 index 00000000000..12567fd5f02 --- /dev/null +++ b/packages/shared/src/components/brand/BrandedTag.tsx @@ -0,0 +1,225 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useRef } from 'react'; +import classNames from 'classnames'; +import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; +import type { TagBrandingStyle } from '../../lib/brand'; +import { Tooltip } from '../tooltip/Tooltip'; +import { SponsoredTooltip } from './SponsoredTooltip'; +import styles from './BrandedTag.module.css'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; + +interface BrandedTagProps { + tag: string; + className?: string; + onClick?: () => void; + /** Render as span instead of button (for use inside links) */ + asSpan?: boolean; + /** Force disable branding even if tag is sponsored */ + disableBranding?: boolean; +} + +/** + * BrandedTag Component + * + * Renders a tag with optional brand sponsorship animation. + * When a tag is sponsored, it animates to show the brand association. + * + * Animation styles: + * - 'suffix': "ai" -> "ai - powered by Copilot" + * - 'replace': "ai" -> "Copilot" + * - 'arrow': "ai" -> "Copilot →" + */ +export const BrandedTag = ({ + tag, + className, + onClick, + asSpan = false, + disableBranding = false, +}: BrandedTagProps): ReactElement => { + const { getSponsoredTag, getHighlightedWordConfig } = useBrandSponsorship(); + const rawSponsorInfo = getSponsoredTag(tag); + // Allow disabling branding externally (e.g., to limit to one sponsored tag per list) + const sponsorInfo = disableBranding + ? { ...rawSponsorInfo, isSponsored: false } + : rawSponsorInfo; + const isAnimated = sponsorInfo.isSponsored && !!sponsorInfo.branding; + const showBranding = isAnimated; + const { logEvent } = useLogContext(); + const hasLoggedHoverRef = useRef(false); + + const handleSponsoredHover = useCallback(() => { + if (!sponsorInfo.isSponsored || hasLoggedHoverRef.current) { + return; + } + hasLoggedHoverRef.current = true; + logEvent({ + event_name: LogEvent.HoverEngagementTooltip, + target_id: tag, + extra: JSON.stringify({ origin: Origin.BrandedTag }), + }); + }, [logEvent, sponsorInfo.isSponsored, tag]); + + const getBrandedContent = (style: TagBrandingStyle): string => { + if (!sponsorInfo.brandName) { + return `#${tag}`; + } + + switch (style) { + case 'suffix': + return `#${tag} - powered by ${sponsorInfo.brandName}`; + case 'replace': + return `#${sponsorInfo.brandName}`; + case 'arrow': + return `${sponsorInfo.brandName} →`; + default: + return `#${tag}`; + } + }; + + const handleClick = (e: React.MouseEvent): void => { + // When used as a span inside a link (asSpan=true), don't intercept - let the parent link handle navigation + // For standalone buttons with branding shown, open brand URL in new tab + if (!asSpan && sponsorInfo.targetUrl && showBranding) { + e.preventDefault(); + e.stopPropagation(); + window.open(sponsorInfo.targetUrl, '_blank', 'noopener,noreferrer'); + return; + } + onClick?.(); + }; + + const baseClassName = classNames( + 'flex h-6 items-center justify-center rounded-8 border border-border-subtlest-tertiary px-2 text-text-quaternary typo-footnote', + className, + ); + + const sponsoredClassName = classNames( + styles.brandedTag, + 'relative flex h-6 items-center overflow-hidden rounded-8 border px-2 typo-footnote', + { + 'border-border-subtlest-tertiary text-text-quaternary': !isAnimated, + [styles.animated]: isAnimated, + }, + className, + ); + + const sponsoredStyle = + isAnimated && sponsorInfo.colors + ? ({ + borderColor: sponsorInfo.colors.primary, + background: `linear-gradient(135deg, ${sponsorInfo.colors.primary}15 0%, ${sponsorInfo.colors.secondary}15 100%)`, + '--brand-primary': sponsorInfo.colors.primary, + } as React.CSSProperties) + : undefined; + + const brandedText = sponsorInfo.branding + ? getBrandedContent(sponsorInfo.branding.style) + : ''; + + const content = ( + <> + {/* Original tag — hidden when branded */} + {!showBranding && ( + + #{tag} + + )} + + {/* Branded content with logo and styled text */} + {showBranding && sponsorInfo.branding && ( + + {sponsorInfo.branding.showLogo !== false && sponsorInfo.brandLogo && ( + {sponsorInfo.brandName + )} + {brandedText} + + )} + + ); + + // Get tooltip config for sponsored tags + const highlightedWordResult = getHighlightedWordConfig([tag]); + const showTooltip = + showBranding && + sponsorInfo.isSponsored && + highlightedWordResult.config && + sponsorInfo.brandName; + + const tooltipContent = showTooltip ? ( + + ) : null; + + // Render as span for use inside links + if (asSpan) { + const spanElement = ( + + {content} + + ); + + if (showTooltip) { + return ( + + {spanElement} + + ); + } + + return spanElement; + } + + // Render as button for standalone use + const buttonElement = ( + + ); + + if (showTooltip) { + return ( + + {buttonElement} + + ); + } + + return buttonElement; +}; diff --git a/packages/shared/src/components/brand/BrandedUpvoteAnimation.spec.tsx b/packages/shared/src/components/brand/BrandedUpvoteAnimation.spec.tsx new file mode 100644 index 00000000000..6350fafbedf --- /dev/null +++ b/packages/shared/src/components/brand/BrandedUpvoteAnimation.spec.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import type { BrandColors, UpvoteAnimationConfig } from '../../lib/brand'; +import { BrandedUpvoteAnimation } from './BrandedUpvoteAnimation'; + +const colors: BrandColors = { + primary: '#6e40c9', + secondary: '#1f6feb', +}; + +const config: UpvoteAnimationConfig = { + type: 'confetti', + particleCount: 10, + duration: 1500, +}; + +describe('BrandedUpvoteAnimation', () => { + it('renders nothing while isActive is false', () => { + const { container } = render( + , + ); + // eslint-disable-next-line testing-library/no-container + expect(container.querySelector('canvas')).toBeNull(); + }); + + it('mounts the canvas once isActive becomes true', () => { + const { container, rerender } = render( + , + ); + // eslint-disable-next-line testing-library/no-container + expect(container.querySelector('canvas')).toBeNull(); + + rerender( + , + ); + + // eslint-disable-next-line testing-library/no-container + expect(container.querySelector('canvas')).not.toBeNull(); + }); +}); diff --git a/packages/shared/src/components/brand/BrandedUpvoteAnimation.tsx b/packages/shared/src/components/brand/BrandedUpvoteAnimation.tsx new file mode 100644 index 00000000000..a4da5293d0b --- /dev/null +++ b/packages/shared/src/components/brand/BrandedUpvoteAnimation.tsx @@ -0,0 +1,277 @@ +import type { ReactElement, CSSProperties } from 'react'; +import React, { + useEffect, + useLayoutEffect, + useRef, + useState, + useCallback, + memo, +} from 'react'; +import type { BrandColors, UpvoteAnimationConfig } from '../../lib/brand'; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + size: number; + color: string; + alpha: number; + rotation: number; + rotationSpeed: number; + type: 'circle' | 'star' | 'square'; +} + +interface BrandedUpvoteAnimationProps { + /** Trigger the animation */ + isActive: boolean; + /** Brand colors for the particles */ + colors: BrandColors; + /** Animation configuration */ + config: UpvoteAnimationConfig; + /** Callback when animation completes */ + onComplete?: () => void; + /** Custom styles for positioning */ + style?: CSSProperties; + /** Custom class name */ + className?: string; +} + +const drawStar = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + rotation: number, +): void => { + const spikes = 5; + const outerRadius = size; + const innerRadius = size / 2; + + ctx.save(); + ctx.translate(x, y); + ctx.rotate(rotation); + ctx.beginPath(); + + for (let i = 0; i < spikes * 2; i += 1) { + const radius = i % 2 === 0 ? outerRadius : innerRadius; + const angle = (i * Math.PI) / spikes - Math.PI / 2; + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius; + + if (i === 0) { + ctx.moveTo(px, py); + } else { + ctx.lineTo(px, py); + } + } + + ctx.closePath(); + ctx.fill(); + ctx.restore(); +}; + +const drawParticle = ( + ctx: CanvasRenderingContext2D, + particle: Particle, +): void => { + ctx.globalAlpha = particle.alpha; + ctx.fillStyle = particle.color; + + switch (particle.type) { + case 'circle': + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + ctx.fill(); + break; + case 'star': + drawStar(ctx, particle.x, particle.y, particle.size, particle.rotation); + break; + case 'square': + ctx.save(); + ctx.translate(particle.x, particle.y); + ctx.rotate(particle.rotation); + ctx.fillRect( + -particle.size / 2, + -particle.size / 2, + particle.size, + particle.size, + ); + ctx.restore(); + break; + default: + break; + } +}; + +/** + * BrandedUpvoteAnimation + * + * A high-performance particle animation component for branded upvotes. + * Uses canvas for smooth 60fps animations with brand-colored particles. + * + * Animation types: + * - confetti: Colorful particles exploding outward + * - ripple: Concentric circles expanding from center + * - burst: Particles shooting outward in all directions + * - glow: Pulsing glow effect with particles + */ +const BrandedUpvoteAnimation = memo( + ({ + isActive, + colors, + config, + onComplete, + style, + className, + }: BrandedUpvoteAnimationProps): ReactElement | null => { + const canvasRef = useRef(null); + const particlesRef = useRef([]); + const animationFrameRef = useRef(null); + const [shouldRender, setShouldRender] = useState(false); + + const createParticle = useCallback( + (centerX: number, centerY: number): Particle => { + const angle = Math.random() * Math.PI * 2; + const speed = 2 + Math.random() * 4; + const colorOptions = [colors.primary, colors.secondary, '#ffffff']; + + return { + x: centerX, + y: centerY, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 2, // Slight upward bias + size: 3 + Math.random() * 5, + color: colorOptions[Math.floor(Math.random() * colorOptions.length)], + alpha: 1, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.2, + type: (['circle', 'star', 'square'] as const)[ + Math.floor(Math.random() * 3) + ], + }; + }, + [colors], + ); + + const animate = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Update and draw particles + const activeParticles: Particle[] = []; + + particlesRef.current.forEach((particle) => { + // Update position + const updatedParticle = { ...particle }; + updatedParticle.x += updatedParticle.vx; + updatedParticle.y += updatedParticle.vy; + updatedParticle.vy += 0.15; // Gravity + updatedParticle.alpha -= 0.015; + updatedParticle.rotation += updatedParticle.rotationSpeed; + updatedParticle.size *= 0.98; // Shrink slightly + + if (updatedParticle.alpha > 0) { + drawParticle(ctx, updatedParticle); + activeParticles.push(updatedParticle); + } + }); + + particlesRef.current = activeParticles; + + // Continue animation if particles remain + if (activeParticles.length > 0) { + animationFrameRef.current = requestAnimationFrame(animate); + } else { + setShouldRender(false); + onComplete?.(); + } + }, [onComplete]); + + // Mount the canvas when activated + useEffect(() => { + if (isActive) { + setShouldRender(true); + } + }, [isActive]); + + // Seed particles and kick off the rAF loop once the canvas is in the DOM + useLayoutEffect(() => { + if (!shouldRender) { + return undefined; + } + + const canvas = canvasRef.current; + if (!canvas) { + return undefined; + } + + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const particleCount = config.particleCount || 30; + const newParticles: Particle[] = []; + + for (let i = 0; i < particleCount; i += 1) { + newParticles.push(createParticle(centerX, centerY)); + } + + particlesRef.current = newParticles; + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [shouldRender, config.particleCount, createParticle, animate]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, []); + + if (!shouldRender) { + return null; + } + + return ( + + ); + }, +); + +BrandedUpvoteAnimation.displayName = 'BrandedUpvoteAnimation'; + +export { BrandedUpvoteAnimation }; diff --git a/packages/shared/src/components/brand/HighlightedWord.spec.tsx b/packages/shared/src/components/brand/HighlightedWord.spec.tsx new file mode 100644 index 00000000000..eac1f31cc92 --- /dev/null +++ b/packages/shared/src/components/brand/HighlightedWord.spec.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { defaultQueryClientTestingConfig } from '../../../__tests__/helpers/tanstack-query'; +import type { EngagementCreative } from '../../lib/engagementAds'; +import { EngagementAdsProvider } from '../../contexts/EngagementAdsContext'; +import { HighlightedBrandText, HighlightedWord } from './HighlightedWord'; + +const creative: EngagementCreative = { + gen_id: 'c1', + promoted_name: 'Copilot', + promoted_body: 'AI pair programming', + promoted_cta: 'Try free', + promoted_url: 'https://example.com', + promoted_logo_img: { + dark: 'https://example.com/logo-dark.png', + light: 'https://example.com/logo-light.png', + }, + promoted_icon_img: { + dark: 'https://example.com/icon-dark.png', + light: 'https://example.com/icon-light.png', + }, + promoted_gradient_start: { dark: '#000', light: '#fff' }, + promoted_gradient_end: { dark: '#111', light: '#eee' }, + tools: ['vscode'], + keywords: ['Copilot'], + tags: ['ai'], +}; + +let queryClient: QueryClient; + +beforeEach(() => { + queryClient = new QueryClient(defaultQueryClientTestingConfig); +}); + +const wrap = ( + children: React.ReactNode, + creatives: EngagementCreative[] = [], +) => + render( + + + {children} + + , + ); + +describe('HighlightedWord', () => { + it('renders the word as plain text when it matches nothing', () => { + wrap(); + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('wraps the word with a tooltip trigger span when matched', () => { + wrap(, [creative]); + const span = screen.getByText('Copilot'); + // The trigger is the styled span; plain text would render as a fragment + expect(span.tagName).toBe('SPAN'); + expect(span.className).toMatch(/cursor-pointer/); + }); +}); + +describe('HighlightedBrandText', () => { + it('renders plain text when no keyword matches', () => { + wrap(, [creative]); + expect(screen.getByText('Learn React today')).toBeInTheDocument(); + }); + + it('wraps the first matched keyword and leaves the surrounding text intact', () => { + const { container } = wrap( + , + [creative], + ); + // The full sentence is still readable end-to-end + expect(container).toHaveTextContent('Try Copilot for AI coding'); + // And the keyword is the element we highlight + const highlighted = screen.getByText('Copilot'); + expect(highlighted.tagName).toBe('SPAN'); + expect(highlighted.className).toMatch(/cursor-pointer/); + }); +}); diff --git a/packages/shared/src/components/brand/HighlightedWord.tsx b/packages/shared/src/components/brand/HighlightedWord.tsx new file mode 100644 index 00000000000..79811cc4e1f --- /dev/null +++ b/packages/shared/src/components/brand/HighlightedWord.tsx @@ -0,0 +1,188 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; +import classNames from 'classnames'; +import { Tooltip } from '../tooltip/Tooltip'; +import { SponsoredTooltip } from './SponsoredTooltip'; +import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; +import type { HighlightStyle } from '../../lib/brand'; +import { findFirstHighlightedKeyword } from '../../lib/brand'; +import { useEngagementAdsContext } from '../../contexts/EngagementAdsContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; + +interface HighlightedWordProps { + /** The word/text to highlight */ + word: string; + /** Tags used to look up the matching creative. When omitted, checks all creatives. */ + tags?: string[]; + className?: string; +} + +/** + * Get Tailwind classes for highlight style + */ +const getHighlightClasses = (style: HighlightStyle): string => { + switch (style) { + case 'dotted': + return 'border-b border-dotted border-accent-onion-default cursor-pointer'; + case 'underline': + return 'underline decoration-1 underline-offset-2 cursor-pointer'; + case 'background': + return 'bg-accent-onion-default/20 rounded px-0.5 -mx-0.5 cursor-pointer'; + default: + return 'underline decoration-1 underline-offset-2 cursor-pointer'; + } +}; + +/** + * HighlightedWord Component + * + * Renders a single highlighted keyword with a sponsored tooltip on hover. + * Uses the brand sponsorship configuration for styling and tooltip content. + */ +export const HighlightedWord = ({ + word, + tags, + className, +}: HighlightedWordProps): ReactElement => { + const { getHighlightedWordConfig } = useBrandSponsorship(); + const { creatives } = useEngagementAdsContext(); + + // When tags are provided, use them to find the creative. + // When omitted, find the first creative whose keywords include this word. + const highlightResult = useMemo(() => { + if (tags?.length) { + return getHighlightedWordConfig(tags); + } + + const lowerWord = word.toLowerCase().trim(); + const match = creatives.find( + (c) => + c.keywords.some((k) => k.toLowerCase() === lowerWord) || + c.tags.some((t) => t.toLowerCase() === lowerWord), + ); + + if (!match) { + return { config: null, brandName: null, brandLogo: null, colors: null }; + } + + return getHighlightedWordConfig(match.tags); + }, [tags, word, creatives, getHighlightedWordConfig]); + + const { config, brandName, brandLogo, colors } = highlightResult; + const { logEvent } = useLogContext(); + const hasLoggedRef = useRef(false); + + const handleMouseEnter = useCallback(() => { + if (hasLoggedRef.current) { + return; + } + hasLoggedRef.current = true; + logEvent({ + event_name: LogEvent.HoverEngagementTooltip, + target_id: word, + extra: JSON.stringify({ origin: Origin.HighlightedKeyword }), + }); + }, [logEvent, word]); + + if (!config || !brandName) { + return <>{word}; + } + + const highlightClasses = getHighlightClasses(config.highlightStyle); + + const tooltipContent = ( + + ); + + return ( + + + {word} + + + ); +}; + +interface HighlightedBrandTextProps { + /** The full text to scan for keywords */ + text: string; + /** Tags used to look up the matching creative. When omitted, checks all creatives. */ + tags?: string[]; + /** Optional className for the container */ + className?: string; +} + +/** + * HighlightedBrandText Component + * + * Scans text for sponsored keywords and wraps them with HighlightedWord components. + * Non-matching text is rendered as-is. + * + * @example + * ```tsx + * + * // "AI" would be highlighted if it's a sponsored keyword + * ``` + */ +export const HighlightedBrandText = ({ + text, + tags, + className, +}: HighlightedBrandTextProps): ReactElement => { + const { creatives, getCreativeForTags } = useEngagementAdsContext(); + + // Resolve the primary creative (keywords list) and a fallback tags list. + // Keywords are scanned first; tags are only used if no keyword matched. + const { keywords, fallbackTags } = useMemo(() => { + if (tags?.length) { + const creative = getCreativeForTags(tags); + + return { + keywords: creative?.keywords ?? [], + fallbackTags: creative?.tags ?? [], + }; + } + + return { + keywords: creatives.flatMap((c) => c.keywords), + fallbackTags: creatives.flatMap((c) => c.tags), + }; + }, [tags, creatives, getCreativeForTags]); + + const match = useMemo(() => { + if (!text) { + return null; + } + // Scan keywords first; only fall back to tags if no keyword matched + return ( + findFirstHighlightedKeyword(text, keywords) ?? + findFirstHighlightedKeyword(text, fallbackTags) + ); + }, [text, keywords, fallbackTags]); + + if (!match) { + return <>{text}; + } + + return ( + + {text.slice(0, match.start)} + + {text.slice(match.end)} + + ); +}; diff --git a/packages/shared/src/components/brand/MentionedToolsWidget.spec.tsx b/packages/shared/src/components/brand/MentionedToolsWidget.spec.tsx new file mode 100644 index 00000000000..e2f55f624c1 --- /dev/null +++ b/packages/shared/src/components/brand/MentionedToolsWidget.spec.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { defaultQueryClientTestingConfig } from '../../../__tests__/helpers/tanstack-query'; +import type { EngagementCreative } from '../../lib/engagementAds'; +import { EngagementAdsProvider } from '../../contexts/EngagementAdsContext'; +import { MentionedToolsWidget } from './MentionedToolsWidget'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; + +const creative: EngagementCreative = { + gen_id: 'c1', + promoted_name: 'Copilot', + promoted_body: 'AI pair programming', + promoted_cta: 'Try free', + promoted_url: 'https://example.com', + promoted_logo_img: { + dark: 'https://example.com/logo-dark.png', + light: 'https://example.com/logo-light.png', + }, + promoted_icon_img: { + dark: 'https://example.com/icon-dark.png', + light: 'https://example.com/icon-light.png', + }, + promoted_gradient_start: { dark: '#000', light: '#fff' }, + promoted_gradient_end: { dark: '#111', light: '#eee' }, + tools: ['VSCode', 'GitHub'], + keywords: ['AI'], + tags: ['ai'], +}; + +let queryClient: QueryClient; + +beforeEach(() => { + queryClient = new QueryClient(defaultQueryClientTestingConfig); +}); + +type RenderOpts = { + postTags: string[]; + creatives?: EngagementCreative[]; + user?: typeof loggedUser | null; + showLogin?: jest.Mock; +}; + +const renderWidget = ({ + postTags, + creatives = [], + user = loggedUser, + showLogin, +}: RenderOpts) => + render( + + + + + , + ); + +describe('MentionedToolsWidget', () => { + it('renders nothing when no creative matches the post tags', () => { + const { container } = renderWidget({ + postTags: ['react'], + creatives: [creative], + }); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when creative has no tools', () => { + const creativeWithoutTools = { ...creative, tools: [] }; + const { container } = renderWidget({ + postTags: ['ai'], + creatives: [creativeWithoutTools], + }); + expect(container).toBeEmptyDOMElement(); + }); + + it('lists each tool from the matching creative', () => { + renderWidget({ postTags: ['ai'], creatives: [creative] }); + expect(screen.getByText('Mentioned tools')).toBeInTheDocument(); + expect(screen.getByText('VSCode')).toBeInTheDocument(); + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + it('prompts anonymous users to log in when a tool is clicked', async () => { + const showLogin = jest.fn(); + renderWidget({ + postTags: ['ai'], + creatives: [creative], + user: null, + showLogin, + }); + + await userEvent.click(screen.getByText('VSCode')); + expect(showLogin).toHaveBeenCalledWith( + expect.objectContaining({ trigger: 'add to stack' }), + ); + }); +}); diff --git a/packages/shared/src/components/brand/MentionedToolsWidget.tsx b/packages/shared/src/components/brand/MentionedToolsWidget.tsx new file mode 100644 index 00000000000..bdc1ccf657b --- /dev/null +++ b/packages/shared/src/components/brand/MentionedToolsWidget.tsx @@ -0,0 +1,291 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useCallback, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../typography/Typography'; +import { VIcon, PlusIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { Tooltip } from '../tooltip/Tooltip'; +import { SponsoredTooltip } from './SponsoredTooltip'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useUserStack } from '../../features/profile/hooks/useUserStack'; +import { UserStackModal } from '../../features/profile/components/stack/UserStackModal'; +import type { AddUserStackInput } from '../../graphql/user/userStack'; +import { webappUrl } from '../../lib/constants'; +import { AuthTriggers } from '../../lib/auth'; +import type { PublicProfile } from '../../lib/user'; +import { useEngagementAdsContext } from '../../contexts/EngagementAdsContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; + +interface Tool { + id: string; + name: string; + icon?: string; + isSponsored?: boolean; +} + +interface MentionedToolsWidgetProps { + postTags: string[]; + className?: string; +} + +/** + * MentionedToolsWidget Component + * + * Displays tools from the matching engagement creative that users can add + * to their profile. The sponsored tools appear first with special styling. + */ +export const MentionedToolsWidget = ({ + postTags, + className, +}: MentionedToolsWidgetProps): ReactElement | null => { + const router = useRouter(); + const { user, showLogin } = useAuthContext(); + const { getHighlightedWordConfig, hasAnySponsoredTag } = + useBrandSponsorship(); + const { getCreativeForTags } = useEngagementAdsContext(); + const { displayToast } = useToastNotification(); + const { logEvent } = useLogContext(); + + const { stackItems, add, remove } = useUserStack(user as PublicProfile); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedToolName, setSelectedToolName] = useState(null); + + const isToolInStack = useCallback( + (toolName: string): boolean => { + return stackItems.some( + (item) => + item.tool.title.toLowerCase() === toolName.toLowerCase() || + item.title?.toLowerCase() === toolName.toLowerCase(), + ); + }, + [stackItems], + ); + + // Extract tools from the matching creative + const mentionedTools = useMemo(() => { + const creative = getCreativeForTags(postTags); + + if (!creative?.tools?.length) { + return []; + } + + return creative.tools.map( + (toolName): Tool => ({ + id: toolName, + name: toolName, + icon: creative.icon, + isSponsored: true, + }), + ); + }, [postTags, getCreativeForTags]); + + const handleToolClick = useCallback( + (tool: Tool) => { + if (!user) { + showLogin({ trigger: AuthTriggers.AddToStack }); + return; + } + + if (isToolInStack(tool.name)) { + router.push(`${webappUrl}${user.username}`); + return; + } + + logEvent({ + event_name: LogEvent.StartAddUserStack, + target_id: tool.name, + }); + setSelectedToolName(tool.name); + setIsModalOpen(true); + }, + [user, showLogin, isToolInStack, router, logEvent], + ); + + const handleAddToStack = useCallback( + async (input: AddUserStackInput) => { + try { + const result = await add(input); + + const newItemId = result?.id; + const toolName = input.title; + + displayToast(`Added ${toolName} to your stack`, { + action: newItemId + ? { + onClick: async () => { + try { + await remove(newItemId); + displayToast(`Removed ${toolName} from your stack`); + } catch (error) { + displayToast('Failed to undo'); + } + }, + copy: 'Undo', + } + : undefined, + }); + } catch (error) { + displayToast('Failed to add to stack'); + throw error; + } + }, + [add, remove, displayToast], + ); + + const handleCloseModal = useCallback(() => { + setIsModalOpen(false); + setSelectedToolName(null); + }, []); + + const hoveredToolsRef = useRef>(new Set()); + + const handleSponsoredToolHover = useCallback( + (toolName: string) => { + if (hoveredToolsRef.current.has(toolName)) { + return; + } + hoveredToolsRef.current.add(toolName); + logEvent({ + event_name: LogEvent.HoverEngagementTooltip, + target_id: toolName, + extra: JSON.stringify({ origin: Origin.MentionedTool }), + }); + }, + [logEvent], + ); + + if (mentionedTools.length === 0) { + return null; + } + + const highlightedWordResult = getHighlightedWordConfig(postTags); + + return ( + <> +
+ + Mentioned tools + + +
+ {mentionedTools.map((tool) => { + const isSponsored = + tool.isSponsored && hasAnySponsoredTag(postTags); + const isInStack = isToolInStack(tool.name); + + const toolItem = ( + + ); + + if (isSponsored && highlightedWordResult.config) { + return ( + + } + side="left" + noArrow + className="!max-w-none !rounded-16 !bg-transparent !p-0" + > + {toolItem} + + ); + } + + return toolItem; + })} +
+
+ + {isModalOpen && ( + + )} + + ); +}; diff --git a/packages/shared/src/components/brand/SponsoredTagHero.tsx b/packages/shared/src/components/brand/SponsoredTagHero.tsx new file mode 100644 index 00000000000..08885e28746 --- /dev/null +++ b/packages/shared/src/components/brand/SponsoredTagHero.tsx @@ -0,0 +1,147 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { OpenLinkIcon } from '../icons'; + +interface SponsoredTagHeroProps { + tag: string; + className?: string; +} + +/** + * Hero banner for sponsored tags + * Shows brand imagery and CTA when a tag is sponsored + * Features animated gradient border and floating orbs + */ +export const SponsoredTagHero = ({ + tag, + className, +}: SponsoredTagHeroProps): ReactElement | null => { + const { getSponsoredTag, getHighlightedWordConfig } = useBrandSponsorship(); + const sponsorInfo = getSponsoredTag(tag); + + if (!sponsorInfo.isSponsored) { + return null; + } + + const highlightedWordConfig = getHighlightedWordConfig([tag]); + const ctaUrl = highlightedWordConfig.config?.ctaUrl; + const ctaText = highlightedWordConfig.config?.ctaText || 'Learn more'; + + const { primary, secondary } = sponsorInfo.colors; + + return ( +
+ {/* Mesh gradient overlay for depth */} +
+ + {/* Animated floating orbs - contained within bounds */} +
+
+
+ + {/* Content - white text for contrast */} +
+
+ {/* Brand logo with glow */} + {sponsorInfo.brandLogo && ( +
+
+ {sponsorInfo.brandName} +
+ )} + +
+ Powered by + + {sponsorInfo.brandName} + + {highlightedWordConfig.config?.tooltipDescription && ( +

+ {highlightedWordConfig.config.tooltipDescription} +

+ )} +
+
+ + {/* CTA button - white style */} + {ctaUrl && ( + + )} +
+ + {/* Inline keyframe styles */} + +
+ ); +}; diff --git a/packages/shared/src/components/brand/SponsoredTooltip.tsx b/packages/shared/src/components/brand/SponsoredTooltip.tsx new file mode 100644 index 00000000000..cafa468d5bf --- /dev/null +++ b/packages/shared/src/components/brand/SponsoredTooltip.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { BrandColors, HighlightedWordConfig } from '../../lib/brand'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; + +interface SponsoredTooltipProps { + config: HighlightedWordConfig; + brandName: string; + brandLogo: string | null; + colors: BrandColors | null; + className?: string; +} + +/** + * SponsoredTooltip Component + * + * A beautiful tooltip content for highlighted words showing brand info and CTA. + * Features gradient background based on brand colors and a prominent CTA button. + */ +export const SponsoredTooltip = ({ + config, + brandName, + brandLogo, + className, +}: SponsoredTooltipProps): ReactElement => { + const handleCtaClick = (e: React.MouseEvent): void => { + e.stopPropagation(); + if (config.ctaUrl) { + window.open(config.ctaUrl, '_blank', 'noopener,noreferrer'); + } + }; + + return ( +
+ {/* Header with logo and brand name */} +
+ {brandLogo && ( + {brandName} + )} +
+ + Powered by + + + {config.tooltipTitle} + +
+
+ + {/* Description */} +

+ {config.tooltipDescription} +

+ + {/* CTA Button */} + {config.ctaText && config.ctaUrl && ( + + )} +
+ ); +}; diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index f4b515461fa..20a909c5be0 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.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 { Post } from '../../../graphql/posts'; import InteractionCounter from '../../InteractionCounter'; @@ -20,6 +20,7 @@ import ConditionalWrapper from '../../ConditionalWrapper'; import { PostTagsPanel } from '../../post/block/PostTagsPanel'; import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip'; import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; export type ActionButtonsVariant = 'grid' | 'list' | 'signal'; @@ -75,6 +76,7 @@ const ActionButtons = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); + const { getUpvoteAnimation } = useBrandSponsorship(); const { isUpvoteActive, @@ -93,6 +95,23 @@ const ActionButtons = ({ closeTagsPanelOnUpvote: variant === 'list', }); + // Get brand animation config if post has sponsored tags + 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; } @@ -176,6 +195,7 @@ const ActionButtons = ({ } > diff --git a/packages/shared/src/components/cards/common/ShowMoreContent.tsx b/packages/shared/src/components/cards/common/ShowMoreContent.tsx index 7ee588abd4a..cba1533e3f8 100644 --- a/packages/shared/src/components/cards/common/ShowMoreContent.tsx +++ b/packages/shared/src/components/cards/common/ShowMoreContent.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from 'react'; import classNames from 'classnames'; import { ClickableText } from '../../buttons/ClickableText'; import { useToggle } from '../../../hooks/useToggle'; +import { HighlightedBrandText } from '../../brand/HighlightedWord'; export type ShowMoreContentEnding = 'ellipsis' | 'punctuation' | 'none'; @@ -81,7 +82,7 @@ export default function ShowMoreContent({ data-testid="tldr-container" > {contentPrefix} - {shownContent} + {showMore.isVisible && ( []>([]); + + // Clear all timeouts on unmount + useEffect(() => { + const timeouts = timeoutRefs.current; + return () => { + timeouts.forEach(clearTimeout); + }; + }, []); + + // Trigger animation when transitioning from inactive to active + useEffect(() => { + if (isUpvoteActive && !prevActiveRef.current && brandAnimation) { + // Start particle animation + setShowAnimation(true); + + // Start rotation animation + setIsRotating(true); + + // After rotation completes, show brand icon + const showBrandTimeout = setTimeout(() => { + if (brandAnimation.brandLogo) { + setShowBrandIcon(true); + } + setIsRotating(false); + }, ROTATION_DURATION_MS); + timeoutRefs.current.push(showBrandTimeout); + + // Then hide the brand icon after it has been visible long enough + const hideBrandTimeout = setTimeout(() => { + setShowBrandIcon(false); + }, ROTATION_DURATION_MS + BRAND_ICON_VISIBLE_MS); + timeoutRefs.current.push(hideBrandTimeout); + } + prevActiveRef.current = isUpvoteActive; + }, [isUpvoteActive, brandAnimation]); + + const handleAnimationComplete = (): void => { + setShowAnimation(false); + }; + + const hasBrandLogo = brandAnimation?.brandLogo; return ( - + + {/* Brand icon - shown after rotation */} + {showBrandIcon && hasBrandLogo ? ( + + ) : ( + + )} + + + {/* Branded upvote animation (particles) */} + {brandAnimation && ( + + )} ); }); diff --git a/packages/shared/src/components/modals/BasePostModal.tsx b/packages/shared/src/components/modals/BasePostModal.tsx index d78c7bf64e2..03b99ab6efa 100644 --- a/packages/shared/src/components/modals/BasePostModal.tsx +++ b/packages/shared/src/components/modals/BasePostModal.tsx @@ -16,6 +16,7 @@ import { LogEvent, TargetType } from '../../lib/log'; import { useLogContext } from '../../contexts/LogContext'; import { useEventListener } from '../../hooks'; import useDebounceFn from '../../hooks/useDebounceFn'; +import { useEngagementAdsContext } from '../../contexts/EngagementAdsContext'; interface BasePostModalProps extends ModalProps { postType: PostType; @@ -57,6 +58,7 @@ function BasePostModal({ usePostReferrerContext()?.usePostReferrer ?? (() => {}); const { logEvent } = useLogContext(); const [scrollNode, setScrollNode] = useState(null); + const { getCreativeForTags } = useEngagementAdsContext(); usePostReferrer({ post }); @@ -85,9 +87,11 @@ function BasePostModal({ { + const creative = getCreativeForTags(post?.tags || []); return { referrer_target_id: post?.id, referrer_target_type: post?.id ? TargetType.Post : undefined, + ...(creative && { gen_id: creative.genId }), }; }} > diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index af328f884ae..0f112328121 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -1,9 +1,8 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import type { QueryKey } from '@tanstack/react-query'; import classNames from 'classnames'; import { - UpvoteIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, @@ -32,6 +31,8 @@ import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useUpdateQuery } from '../../hooks/useUpdateQuery'; import { Tooltip } from '../tooltip/Tooltip'; import ConditionalWrapper from '../ConditionalWrapper'; +import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; +import { UpvoteButtonIcon } from '../cards/common/UpvoteButtonIcon'; interface PostActionsProps { post: Post; @@ -56,11 +57,29 @@ export function PostActions({ sendingUser: user, receivingUser: post.author as LoggedUser | undefined, }); + const { getUpvoteAnimation } = useBrandSponsorship(); const { toggleUpvote, toggleDownvote } = useVotePost(); const isUpvoteActive = post?.userState?.vote === UserVote.Up; const isDownvoteActive = post?.userState?.vote === UserVote.Down; + // Get brand animation config if post has sponsored tags + 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]); + const { toggleBookmark } = useBookmarkPost(); const onToggleBookmark = async () => { @@ -205,7 +224,12 @@ export function PostActions({ id="upvote-post-btn" pressed={isUpvoteActive} onClick={onToggleUpvote} - icon={} + icon={ + + } aria-label="Upvote" variant={ButtonVariant.Tertiary} color={ButtonColor.Avocado} diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index 9feb64a7ad8..428fc28336f 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -15,6 +15,7 @@ import { SourceType } from '../../graphql/sources'; import EntityCardSkeleton from '../cards/entity/EntityCardSkeleton'; import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; +import { MentionedToolsWidget } from '../brand/MentionedToolsWidget'; import { PostSignupWidget } from './PostSignupWidget'; const UserEntityCard = dynamic( @@ -95,6 +96,7 @@ export function PostWidgets({ postId={post.id} className={{ container: cardClasses }} /> + void; tag: string; + /** Render the BrandedTag wrapper for engagement-ads styling. Off by default — preserves the original chip layout when no creative is present. */ + useBrandedRenderer?: boolean; + disableBranding?: boolean; } const Chip = ({ @@ -50,66 +55,128 @@ const PostTagItem = ({ isFollowed, onFollow, tag, + useBrandedRenderer, + disableBranding, }: PostTagItemProps): ReactElement => { - if (isFollowed) { + // Default rendering — matches the pre-engagement-ads layout exactly. + // Used when no creative is sponsoring any tag in the list. + if (!useBrandedRenderer) { + if (isFollowed) { + return ( + + + #{tag} + + + ); + } + return ( - - - #{tag} - - + + + {`#${tag}`} + + + +