diff --git a/.cursor/plans/micro-interaction_ads_platform_76f79a28.plan.md b/.cursor/plans/micro-interaction_ads_platform_76f79a28.plan.md new file mode 100644 index 00000000000..ed510fb09c7 --- /dev/null +++ b/.cursor/plans/micro-interaction_ads_platform_76f79a28.plan.md @@ -0,0 +1,429 @@ +--- +name: Micro-Interaction Ads Platform +overview: "Build a micro-interaction advertising system allowing brands to sponsor UI elements: tags, upvote animations, external store coupons in wallet, promotional checklists with core rewards, and highlighted word tooltips." +todos: + - id: brand-types + content: Create brand configuration types in packages/shared/src/lib/brand.ts + status: completed + - id: brand-hook + content: Implement useBrandSponsorship hook with GrowthBook integration + status: completed + - id: tag-branding + content: Build BrandedTag component with animation using framer-motion + status: completed + - id: greeting-sponsor + content: Create SponsoredGreeting component and integrate with CardWelcome + status: cancelled + - id: upvote-animation + content: Implement branded upvote animation with particle effects + status: completed + - id: wallet-coupons + content: Add "My Coupons" section to wallet for external store discount codes + status: completed + - id: promoted-checklist + content: Build PromotedChecklist popover with trigger banner and task verification + status: completed + - id: highlighted-word + content: Build HighlightedWord component with sponsored tooltip on hover/click + status: completed + - id: coupons-page + content: Create dedicated coupons page at /settings/coupons with search and favorites + status: completed + - id: coupon-widget + content: Build SponsoredCouponWidget for post modal sidebar + status: completed +isProject: false +--- + +# Micro-Interaction Ads Platform + +## Architecture Overview + +```mermaid +graph TB + subgraph DataLayer [Data Layer] + BrandConfig[BrandConfig Interface] + useBrandSponsorship[useBrandSponsorship Hook] + GrowthBook[Feature Flags] + end + + subgraph Features [Micro-Interaction Features] + TagBranding[Tag Branding ✅] + UpvoteAnimation[Upvote Animation ✅] + WalletCoupon[Wallet Coupons ✅] + CouponsPage[Coupons Page ✅] + CouponWidget[Coupon Widget ✅] + PromotedChecklist[Promoted Checklist ✅] + HighlightedWord[Highlighted Word Tooltip ✅] + end + + BrandConfig --> useBrandSponsorship + GrowthBook --> useBrandSponsorship + useBrandSponsorship --> TagBranding + useBrandSponsorship --> UpvoteAnimation + useBrandSponsorship --> WalletCoupon + useBrandSponsorship --> CouponWidget + WalletCoupon --> CouponsPage + useBrandSponsorship --> PromotedChecklist + useBrandSponsorship --> HighlightedWord +``` + + + +## 1. Core Brand Configuration Types ✅ + +Create shared types in `[packages/shared/src/lib/brand.ts](packages/shared/src/lib/brand.ts)`: + +```typescript +interface BrandConfig { + id: string; + name: string; // "Copilot" + slug: string; // "copilot" + colors: { + primary: string; // Brand primary color + secondary: string; + gradient?: string; + }; + logo: string; // Brand logo URL + sponsoredTags: string[]; // ["ai", "copilot", "github"] + + // Feature-specific configs + tagBranding?: TagBrandingConfig; + upvoteAnimation?: UpvoteAnimationConfig; + couponCampaign?: CouponCampaignConfig; + promotedChecklist?: PromotedChecklistConfig; + highlightedWord?: HighlightedWordConfig; +} +``` + +**Mock Brand Config (GitHub Copilot):** + +```typescript +const MOCK_COPILOT_BRAND: BrandConfig = { + id: 'copilot', + name: 'GitHub Copilot', + slug: 'copilot', + colors: { + primary: '#6e40c9', // Copilot purple + secondary: '#1f6feb', // GitHub blue + gradient: 'linear-gradient(135deg, #6e40c9 0%, #1f6feb 100%)', + }, + logo: 'https://github.githubassets.com/images/icons/copilot/cp-head-square.png', + sponsoredTags: ['ai', 'copilot', 'github', 'machine-learning'], + // ... feature configs +}; +``` + +## 2. Feature: Tag Branding ✅ + +**Files to modify:** + +- `[packages/shared/src/components/tags/TagElement.tsx](packages/shared/src/components/tags/TagElement.tsx)` +- `[packages/shared/src/components/cards/common/PostTags.tsx](packages/shared/src/components/cards/common/PostTags.tsx)` + +**Implementation:** + +- Create `BrandedTag` wrapper component +- Animate tag text transition after 1 second delay +- Add brand suffix ("powered by Copilot") or brand arrow ("Copilot ->") +- Use framer-motion for smooth text transitions + +```typescript +interface TagBrandingConfig { + style: 'suffix' | 'replace' | 'arrow'; // "ai - powered by Copilot" | "#Copilot" | "#Copilot ->" + delay: number; // Animation delay in ms + targetUrl?: string; // Optional click-through URL +} +``` + +## 3. Feature: Greeting Sponsorship (REMOVED) + +~~**Location:** Modify existing Log page greeting (`/log`)~~ + +**Status:** This feature was removed from the /log page. The greeting sponsorship types remain in the codebase for potential future use in other locations. + +## 4. Feature: Branded Upvote Animation ✅ + +**Files to modify:** + +- `[packages/shared/src/components/cards/common/UpvoteButtonIcon.tsx](packages/shared/src/components/cards/common/UpvoteButtonIcon.tsx)` +- `[packages/shared/src/hooks/vote/useVotePost.ts](packages/shared/src/hooks/vote/useVotePost.ts)` + +**Implementation:** + +- Add framer-motion for animation (already used in Log cards) +- Create particle/confetti effect using brand colors +- Trigger animation when upvoting posts with matching tags +- Animation options: confetti, ripple, burst, glow + +```typescript +interface UpvoteAnimationConfig { + type: 'confetti' | 'ripple' | 'burst' | 'glow'; + particleCount?: number; + duration: number; +} +``` + +## 5. Feature: Wallet Coupons (External Store Codes) ✅ + +Brands can send **external discount codes** to users' wallets. Users copy these codes and redeem them on the brand's external store (e.g., "20% off Copilot Pro" redeemed at GitHub checkout). + +**Files created:** + +- `packages/shared/src/components/wallet/CouponCard.tsx` - Standalone coupon display card ✅ +- `packages/shared/src/components/wallet/SponsoredCouponWidget.tsx` - Post modal sidebar widget ✅ +- `packages/webapp/pages/settings/coupons.tsx` - Full coupons page with search & favorites ✅ + +**Files modified:** + +- `[packages/webapp/pages/wallet.tsx](packages/webapp/pages/wallet.tsx)` - Integrated coupon section ✅ +- `[packages/shared/src/components/ProfileMenu/sections/MainSection.tsx](packages/shared/src/components/ProfileMenu/sections/MainSection.tsx)` - Added coupons link ✅ +- `[packages/shared/src/components/profile/ProfileSettingsMenu.tsx](packages/shared/src/components/profile/ProfileSettingsMenu.tsx)` - Added coupons menu item ✅ + +**Implementation:** + +- Display coupon codes users can copy to clipboard +- Show brand logo, discount description, and expiration date +- "Redeem" button links to brand's external store +- Coupon states: active, used (self-reported), expired +- Toast notification when new coupon is received + +```typescript +interface BrandCoupon { + id: string; + brandId: string; + code: string; // "COPILOT20" - the actual discount code + description: string; // "20% off Copilot Pro subscription" + expiresAt: Date; + redeemUrl: string; // "https://github.com/copilot/checkout" + termsUrl?: string; // Link to terms & conditions + isUsed: boolean; // User marks as used (optional tracking) +} + +interface CouponCampaignConfig { + coupons: BrandCoupon[]; +} +``` + +**UI Flow:** + +1. Brand sends coupon to eligible users (via backend) +2. User sees notification: "You received a coupon from Copilot!" +3. Coupon appears in wallet under "My Coupons" section +4. User clicks "Copy Code" -> code copied to clipboard +5. User clicks "Redeem" -> opens brand's external store in new tab +6. User can optionally mark coupon as "Used" + +**Additional Implementations:** + +### Dedicated Coupons Page (`/settings/coupons`) ✅ + +A full-featured coupons management page in settings: + +- **Search**: Filter coupons by company, category, title, or description +- **Favorites**: Star coupons to pin them to the top (persisted in localStorage) +- **Card Grid**: Responsive 2-column grid on tablet+ +- **Modal Details**: Click coupon card to view full details with: + - Copy code button + - Step-by-step redemption instructions + - Direct link to brand website +- **Mock Data**: 6 sample coupons (GitHub, JetBrains, Vercel, Notion, Figma, Linear) + +### Post Modal Sidebar Widget (`SponsoredCouponWidget`) ✅ + +Shows contextual coupons in post modal sidebar when: + +- Post has tags matching the active brand's sponsored tags +- Brand has active coupon campaign +- Coupon is not expired or used + +Features: + +- Brand-colored gradient border +- Copy code on click +- "Redeem at {domain}" CTA button + +## 6. Feature: Promoted Checklist ✅ + +**Location:** Popover triggered by a promotional banner/button + +**Files created:** + +- `packages/shared/src/components/checklist/PromotedChecklist.tsx` - Main checklist UI with hero header, animated coins, progress bar ✅ +- `packages/shared/src/components/checklist/PromotedChecklistBanner.tsx` - Promotional banner for sidebar/feed ✅ +- `packages/shared/src/components/checklist/PromotedChecklistMenuItem.tsx` - Compact menu item variant ✅ +- `packages/shared/src/hooks/usePromotedChecklist.ts` - State management with localStorage persistence ✅ + +**Leverage existing:** + +- `[packages/shared/src/hooks/useChecklist.ts](packages/shared/src/hooks/useChecklist.ts)` - Checklist logic +- `[packages/shared/src/graphql/actions.ts](packages/shared/src/graphql/actions.ts)` - ActionType enum +- Existing popover patterns in the codebase + +**Implementation:** + +- Show a promotional banner in the feed or sidebar: "Complete the Copilot Challenge - Earn 500 Cores!" +- Clicking banner opens a popover with task list +- Each task has a "Verify" button (for prototype: mock verification with localStorage) +- Progress bar showing completed tasks +- Award cores via existing `AWARD_MUTATION` (mocked for prototype) + +```typescript +interface PromotedChecklistConfig { + title: string; // "Copilot Challenge" + description: string; // "Complete tasks to earn cores" + tasks: PromotedTask[]; + totalReward: number; // Total cores for completion +} + +interface PromotedTask { + id: string; + title: string; // "Follow @GitHubCopilot on X" + description?: string; + reward: number; // Cores awarded per task + verifyUrl?: string; // External link to complete task + icon?: string; // Task icon (e.g., X logo for follow task) +} +``` + +**Example tasks for Copilot:** + +1. "Follow @GitHubCopilot on X" - 100 cores +2. "Try Copilot free for 30 days" - 200 cores +3. "Star the Copilot repo on GitHub" - 100 cores +4. "Share your Copilot experience" - 100 cores + +## 7. Feature: Highlighted Word Tooltip ✅ + +**Location:** Post content, comments, article previews - anywhere text is displayed + +**Files to create:** + +- `packages/shared/src/components/brand/HighlightedWord.tsx` - Highlighted word with tooltip +- `packages/shared/src/components/brand/SponsoredTooltip.tsx` - Tooltip content with brand info + +**Implementation:** + +- Scan text content for configured keywords (e.g., "AI", "machine learning", "copilot") +- Wrap matched keywords with `HighlightedWord` component +- On hover/click, show a sponsored tooltip with brand info and CTA +- Tooltip includes: brand logo, short message, and optional action button +- Subtle highlight styling (dotted underline or background) to indicate sponsorship +- Track impressions and clicks for analytics + +```typescript +interface HighlightedWordConfig { + keywords: string[]; // Words to highlight (case-insensitive) + tooltipTitle: string; // "Powered by GitHub Copilot" + tooltipDescription: string; // "AI pair programming for developers" + ctaText?: string; // "Try Free" button text + ctaUrl?: string; // Click-through URL + highlightStyle: 'underline' | 'background' | 'dotted'; + triggerOn: 'hover' | 'click' | 'both'; +} +``` + +**Example configuration for Copilot:** + +```typescript +highlightedWord: { + keywords: ['AI', 'artificial intelligence', 'code completion', 'copilot'], + tooltipTitle: 'GitHub Copilot', + tooltipDescription: 'Your AI pair programmer. Write code faster with intelligent suggestions.', + ctaText: 'Try Free for 30 Days', + ctaUrl: 'https://github.com/features/copilot', + highlightStyle: 'dotted', + triggerOn: 'hover', +} +``` + +**UI Behavior:** + +1. User reads post content containing keyword "AI" +2. "AI" appears with subtle dotted underline indicating it's interactive +3. On hover (or click based on config), tooltip appears above the word +4. Tooltip shows: Copilot logo, "Your AI pair programmer", and "Try Free" button +5. Clicking CTA opens brand URL in new tab +6. Tooltip has "Sponsored" label for transparency + +**Integration points:** + +- Post cards (title, description) +- Post page content +- Comments section +- Search results snippets + +## 8. Central Hook: useBrandSponsorship ✅ + +Create `[packages/shared/src/hooks/useBrandSponsorship.ts](packages/shared/src/hooks/useBrandSponsorship.ts)`: + +```typescript +const useBrandSponsorship = () => { + const { value: brandConfig } = useConditionalFeature({ + feature: 'brand_sponsorship_config', + shouldEvaluate: true, + }); + + return { + activeBrand: brandConfig, + getSponsoredTag: (tag: string) => /* ... */, + getUpvoteAnimation: (postTags: string[]) => /* ... */, + getCoupons: () => /* ... */, + getPromotedChecklist: () => /* ... */, + getHighlightedWordConfig: () => /* ... */, + isKeywordHighlighted: (word: string) => /* ... */, + }; +}; +``` + +## File Structure + +``` +packages/shared/src/ +├── lib/ +│ └── brand.ts # Brand types, mock data, and utilities ✅ +├── hooks/ +│ ├── useBrandSponsorship.ts # Central brand hook ✅ +│ └── usePromotedChecklist.ts # Checklist state management ✅ +├── components/ +│ ├── brand/ +│ │ ├── BrandedTag.tsx # Tag branding with animation ✅ +│ │ ├── BrandedTag.module.css # Tag animation styles ✅ +│ │ ├── BrandedUpvoteAnimation.tsx # Confetti/particle animation ✅ +│ │ ├── HighlightedWord.tsx # Highlighted keyword with tooltip ✅ +│ │ └── SponsoredTooltip.tsx # Tooltip content component ✅ +│ ├── checklist/ +│ │ ├── PromotedChecklist.tsx # Main checklist UI ✅ +│ │ ├── PromotedChecklist.module.css # Checklist styles ✅ +│ │ ├── PromotedChecklistBanner.tsx # Promotional banner component ✅ +│ │ ├── PromotedChecklistBanner.module.css # Banner styles ✅ +│ │ ├── PromotedChecklistMenuItem.tsx # Menu item variant ✅ +│ │ └── PromotedChecklistMenuItem.module.css # Menu item styles ✅ +│ └── wallet/ +│ ├── CouponCard.tsx # Standalone coupon card ✅ +│ └── SponsoredCouponWidget.tsx # Post modal sidebar coupon widget ✅ + +packages/webapp/ +└── pages/ + ├── wallet.tsx # Modified for coupon section ✅ + └── settings/ + └── coupons.tsx # Full coupons page with search & favorites ✅ +``` + +## Design Considerations for Future Admin Panel + +The `BrandConfig` interface is designed to be: + +- Serializable to JSON for storage in backend +- Configurable via GrowthBook feature flags initially +- Migrated to dedicated admin API later + +Admin panel would allow brands to: + +- Upload logo and set brand colors +- Select which tags to sponsor +- Design upvote animations +- Create and distribute external store coupon codes to users +- Build promotional checklists with core rewards +- Configure highlighted keywords with custom tooltips and CTAs + diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 99382f176a6..d3cd7ca2bde 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -41,6 +41,8 @@ import { LogExtraContextProvider } from '../contexts/LogExtraContext'; import { SquadAdList } from './cards/ad/squad/SquadAdList'; import { SquadAdGrid } from './cards/ad/squad/SquadAdGrid'; import { adLogEvent, 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'; @@ -186,6 +188,7 @@ export const withFeedLogExtraContext = ( props: FeedItemComponentProps, ): ReactElement | null => { const { item } = props; + const { creatives } = useEngagementAdsContext(); if ([FeedItemType.Ad, FeedItemType.Post].includes(item?.type)) { return ( @@ -207,6 +210,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/ProfileMenu/sections/MainSection.tsx b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx index 9043bca3724..949d3806133 100644 --- a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx @@ -3,11 +3,11 @@ import type { ReactElement } from 'react'; import { ProfileSection } from '../ProfileSection'; import { - AnalyticsIcon, CoinIcon, DevCardIcon, - MedalBadgeIcon, UserIcon, + AnalyticsIcon, + MedalBadgeIcon, } from '../../icons'; import { settingsUrl, walletUrl, webappUrl } from '../../../lib/constants'; import { useAuthContext } from '../../../contexts/AuthContext'; @@ -40,6 +40,12 @@ export const MainSection = (): ReactElement => { href: `${settingsUrl}/customization/devcard`, icon: DevCardIcon, }, + // TODO: Re-enable when ready + // { + // title: 'Coupons', + // href: `${settingsUrl}/coupons`, + // icon: GiftIcon, + // }, { title: 'Analytics', href: `${webappUrl}analytics`, 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.tsx b/packages/shared/src/components/brand/BrandedTag.tsx new file mode 100644 index 00000000000..53a475f8f8f --- /dev/null +++ b/packages/shared/src/components/brand/BrandedTag.tsx @@ -0,0 +1,207 @@ +import type { ReactElement } from 'react'; +import React 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'; + +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 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; +}; + +export default BrandedTag; diff --git a/packages/shared/src/components/brand/BrandedUpvoteAnimation.tsx b/packages/shared/src/components/brand/BrandedUpvoteAnimation.tsx new file mode 100644 index 00000000000..e6e38b99cf1 --- /dev/null +++ b/packages/shared/src/components/brand/BrandedUpvoteAnimation.tsx @@ -0,0 +1,275 @@ +import type { ReactElement, CSSProperties } from 'react'; +import React, { useEffect, 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]); + + // Trigger animation when isActive becomes true + useEffect(() => { + if (!isActive) { + return undefined; + } + + // Start animation + setShouldRender(true); + + // Small delay to ensure canvas is mounted + const timeoutId = setTimeout(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + + // Create particles based on config + const particleCount = config.particleCount || 30; + const newParticles: Particle[] = []; + + for (let i = 0; i < particleCount; i += 1) { + newParticles.push(createParticle(centerX, centerY)); + } + + particlesRef.current = newParticles; + + // Start animation loop + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + animationFrameRef.current = requestAnimationFrame(animate); + }, 50); + + // Cleanup + return () => { + clearTimeout(timeoutId); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isActive, 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 }; +export default BrandedUpvoteAnimation; diff --git a/packages/shared/src/components/brand/HighlightedWord.tsx b/packages/shared/src/components/brand/HighlightedWord.tsx new file mode 100644 index 00000000000..2a67088cdc8 --- /dev/null +++ b/packages/shared/src/components/brand/HighlightedWord.tsx @@ -0,0 +1,179 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo, Fragment } 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 { findHighlightedKeywords } from '../../lib/brand'; +import { useEngagementAdsContext } from '../../contexts/EngagementAdsContext'; + +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), + ); + + 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; + + if (!config || !brandName) { + return <>{word}; + } + + const highlightClasses = getHighlightClasses(config.highlightStyle); + + const tooltipContent = ( + + ); + + return ( + + + {word} + + + ); +}; + +interface HighlightedTextProps { + /** 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; +} + +/** + * HighlightedText 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 HighlightedText = ({ + text, + tags, + className, +}: HighlightedTextProps): ReactElement => { + const { getHighlightedWordConfig } = useBrandSponsorship(); + const { creatives } = useEngagementAdsContext(); + + // Build a highlight config: from specific tags, or by merging all creatives' keywords + const config = useMemo(() => { + if (tags?.length) { + return getHighlightedWordConfig(tags).config; + } + + if (!creatives.length) { + return null; + } + + // Merge all creatives' keywords into one config for text scanning + const allKeywords = creatives.flatMap((c) => c.keywords); + + if (!allKeywords.length) { + return null; + } + + return { + keywords: allKeywords, + highlightStyle: 'dotted' as const, + triggerOn: 'hover' as const, + tooltipTitle: '', + tooltipDescription: '', + }; + }, [tags, creatives, getHighlightedWordConfig]); + + const parts = useMemo((): ReactNode[] => { + if (!config || !text) { + return [text]; + } + + const matches = findHighlightedKeywords(text, config); + + if (matches.length === 0) { + return [text]; + } + + // Only highlight the first match + const match = matches[0]; + + return [ + text.slice(0, match.start), + , + text.slice(match.end), + ]; + }, [text, tags, config]); + + return {parts}; +}; + +export default HighlightedWord; diff --git a/packages/shared/src/components/brand/MentionedToolsWidget.tsx b/packages/shared/src/components/brand/MentionedToolsWidget.tsx new file mode 100644 index 00000000000..588b47200bc --- /dev/null +++ b/packages/shared/src/components/brand/MentionedToolsWidget.tsx @@ -0,0 +1,263 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useCallback, 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'; + +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 { 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; + } + + setSelectedToolName(tool.name); + setIsModalOpen(true); + }, + [user, showLogin, isToolInStack, router], + ); + + 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); + }, []); + + 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" + className="no-arrow !max-w-none !rounded-16 !bg-transparent !p-0" + > + {toolItem} + + ); + } + + return toolItem; + })} +
+
+ + {isModalOpen && ( + + )} + + ); +}; + +export default MentionedToolsWidget; diff --git a/packages/shared/src/components/brand/SponsoredTagHero.tsx b/packages/shared/src/components/brand/SponsoredTagHero.tsx new file mode 100644 index 00000000000..b5ff37f9fff --- /dev/null +++ b/packages/shared/src/components/brand/SponsoredTagHero.tsx @@ -0,0 +1,149 @@ +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} +
+ )} + +
+ Sponsored by + + {sponsorInfo.brandName} + + {highlightedWordConfig.config?.tooltipDescription && ( + + {highlightedWordConfig.config.tooltipDescription} + + )} +
+
+ + {/* CTA button - white style */} + {ctaUrl && ( + + )} +
+ + {/* Inline keyframe styles */} + +
+ ); +}; + +export default SponsoredTagHero; diff --git a/packages/shared/src/components/brand/SponsoredTooltip.tsx b/packages/shared/src/components/brand/SponsoredTooltip.tsx new file mode 100644 index 00000000000..8b6ccd96691 --- /dev/null +++ b/packages/shared/src/components/brand/SponsoredTooltip.tsx @@ -0,0 +1,92 @@ +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, + colors, + 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} + )} +
+ + Sponsored + + + {config.tooltipTitle} + +
+
+ + {/* Description */} +

+ {config.tooltipDescription} +

+ + {/* CTA Button */} + {config.ctaText && config.ctaUrl && ( + + )} +
+ ); +}; + +export default SponsoredTooltip; diff --git a/packages/shared/src/components/cards/TrendingTagsCard.tsx b/packages/shared/src/components/cards/TrendingTagsCard.tsx new file mode 100644 index 00000000000..683053fc34c --- /dev/null +++ b/packages/shared/src/components/cards/TrendingTagsCard.tsx @@ -0,0 +1,220 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import classNames from 'classnames'; +import Link from '../utilities/Link'; +import { webappUrl } from '../../lib/constants'; +import { ArrowIcon, TrendingIcon } from '../icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; + +// Mock trending tags data - in production this would come from an API +const TRENDING_TAGS = [ + 'ai', + 'webdev', + 'react', + 'typescript', + 'devops', + 'javascript', + 'python', + 'nodejs', + 'cloud', + 'docker', + 'kubernetes', + 'rust', + 'golang', + 'vue', + 'nextjs', + 'aws', + 'security', + 'database', + 'api', + 'opensource', +]; + +interface TrendingTagsCardProps { + className?: string; + style?: CSSProperties; +} + +interface TagChipProps { + tag: string; + href: string; + isActive?: boolean; +} + +const TagChip = ({ tag, href, isActive }: TagChipProps): ReactElement => ( + + + {tag} + + +); + +// Mobile card version - shown only on mobile +const MobileTrendingCard = ({ + className, +}: { + className?: string; +}): ReactElement => ( +
+ {/* Header */} +
+ + + Trending tags + +
+ + {/* Tags - show first 8 on mobile */} +
+ {TRENDING_TAGS.slice(0, 8).map((tag) => ( + + + #{tag} + + + ))} +
+
+); + +// Desktop strip version with scroll +const DesktopTrendingStrip = ({ + className, + style, +}: { + className?: string; + style?: CSSProperties; +}): ReactElement => { + const scrollContainerRef = useRef(null); + const [showLeftArrow, setShowLeftArrow] = useState(false); + const [showRightArrow, setShowRightArrow] = useState(true); + + const checkScrollPosition = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + const { scrollLeft, scrollWidth, clientWidth } = container; + const isAtStart = scrollLeft <= 10; + const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 10; + setShowLeftArrow(!isAtStart); + setShowRightArrow(!isAtEnd); + }, []); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) { + return undefined; + } + + checkScrollPosition(); + container.addEventListener('scroll', checkScrollPosition); + + return () => { + container.removeEventListener('scroll', checkScrollPosition); + }; + }, [checkScrollPosition]); + + const scrollLeftFn = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + container.scrollBy({ left: -120, behavior: 'smooth' }); + }, []); + + const scrollRightFn = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + container.scrollBy({ left: 120, behavior: 'smooth' }); + }, []); + + return ( +