Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
429 changes: 429 additions & 0 deletions .cursor/plans/micro-interaction_ads_platform_76f79a28.plan.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/shared/src/components/FeedItemComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`,
Expand Down
62 changes: 62 additions & 0 deletions packages/shared/src/components/brand/BrandedTag.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
207 changes: 207 additions & 0 deletions packages/shared/src/components/brand/BrandedTag.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<span className={classNames(styles.tagContent, 'whitespace-nowrap')}>
#{tag}
</span>
)}

{/* Branded content with logo and styled text */}
{showBranding && sponsorInfo.branding && (
<span
className={classNames(
styles.brandedContent,
styles.fadeIn,
'flex items-center justify-center gap-1.5 whitespace-nowrap text-text-tertiary',
)}
>
{sponsorInfo.branding.showLogo !== false && sponsorInfo.brandLogo && (
<img
src={sponsorInfo.brandLogo}
alt={sponsorInfo.brandName || ''}
className={classNames(styles.brandLogo, 'size-3.5 rounded-full')}
/>
)}
<span className={styles.brandText}>{brandedText}</span>
</span>
)}
</>
);

// Get tooltip config for sponsored tags
const highlightedWordResult = getHighlightedWordConfig([tag]);
const showTooltip =
showBranding &&
sponsorInfo.isSponsored &&
highlightedWordResult.config &&
sponsorInfo.brandName;

const tooltipContent = showTooltip ? (
<SponsoredTooltip
config={highlightedWordResult.config}
brandName={sponsorInfo.brandName}
brandLogo={sponsorInfo.brandLogo}
colors={sponsorInfo.colors}
/>
) : null;

// Render as span for use inside links
if (asSpan) {
const spanElement = (
<span
role="presentation"
onClick={handleClick}
className={sponsorInfo.isSponsored ? sponsoredClassName : baseClassName}
style={sponsorInfo.isSponsored ? sponsoredStyle : undefined}
>
{content}
</span>
);

if (showTooltip) {
return (
<Tooltip
content={tooltipContent}
side="bottom"
className="no-arrow !max-w-none !rounded-16 !bg-transparent !p-0"
>
{spanElement}
</Tooltip>
);
}

return spanElement;
}

// Render as button for standalone use
const buttonElement = (
<button
type="button"
onClick={handleClick}
className={sponsorInfo.isSponsored ? sponsoredClassName : baseClassName}
style={sponsorInfo.isSponsored ? sponsoredStyle : undefined}
>
{content}
</button>
);

if (showTooltip) {
return (
<Tooltip
content={tooltipContent}
side="bottom"
className="!max-w-none !rounded-16 !bg-transparent !p-0 [&_.TooltipArrow]:hidden"
>
{buttonElement}
</Tooltip>
);
}

return buttonElement;
};

export default BrandedTag;
Loading
Loading