diff --git a/src/components/common/ActionAttributionLabel.tsx b/src/components/common/ActionAttributionLabel.tsx new file mode 100644 index 00000000..0f6f1c6a --- /dev/null +++ b/src/components/common/ActionAttributionLabel.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { Check, Copy } from 'lucide-react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { + ACTOR_ROLE_BADGE_CLASSES, + type ActionAttribution, +} from '@/utils/actionAttribution.utils'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; +import { truncateWalletAddress } from '@/utils/referral.utils'; +import showToast from '@/utils/toast.util'; + +interface ActionAttributionLabelProps { + attribution: ActionAttribution; + /** Lead-in copy, e.g. "Posted by" for announcements or "by" for activity. */ + prefix?: string; + className?: string; +} + +/** + * Shows who performed a creator-key action (#1224). Primary-creator actions + * get a "Creator" label; co-creator actions get their role badge. The wallet + * opens a popover with the full address and a copy button — a click-driven + * popover rather than a hover tooltip so it works on touch and keyboard too. + */ +export default function ActionAttributionLabel({ + attribution, + prefix, + className, +}: ActionAttributionLabelProps) { + const [copied, setCopied] = useState(false); + const { wallet, role } = attribution; + const isCreator = role === 'creator'; + + const handleCopy = async () => { + try { + await copyTextToClipboard(wallet); + } catch { + showToast.error('Could not copy address'); + return; + } + setCopied(true); + showToast.success('Address copied'); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + {prefix && {prefix}} + !open && setCopied(false)}> + + + + +

+ {isCreator ? 'Creator wallet' : `${role} co-creator wallet`} +

+
+ + {wallet} + + +
+
+
+ + {isCreator ? 'Creator' : role} + +
+ ); +} diff --git a/src/components/common/AnnouncementItem.tsx b/src/components/common/AnnouncementItem.tsx index 5e90759b..a029f77b 100644 --- a/src/components/common/AnnouncementItem.tsx +++ b/src/components/common/AnnouncementItem.tsx @@ -11,6 +11,7 @@ import { type Announcement, } from '@/utils/announcements.utils'; import { useRelativeTime } from '@/hooks/useRelativeTime'; +import ActionAttributionLabel from '@/components/common/ActionAttributionLabel'; export interface AnnouncementItemProps { announcement: Announcement; @@ -55,6 +56,13 @@ const AnnouncementItem: React.FC = ({

{announcement.title}

+ {announcement.postedBy && ( + + )} {!open && announcement.summary && (

{announcement.summary}

)} diff --git a/src/components/common/BadgeProgressTracker.tsx b/src/components/common/BadgeProgressTracker.tsx new file mode 100644 index 00000000..28bc74eb --- /dev/null +++ b/src/components/common/BadgeProgressTracker.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { format, isValid, parseISO } from 'date-fns'; +import { CheckCircle2 } from 'lucide-react'; +import { Progress } from '@/components/ui/progress'; +import Skeleton from '@/components/ui/skeleton'; +import { + useWalletBadgeMetrics, + useWalletBadges, + useWalletHoldings, +} from '@/hooks/useWallet'; +import { cn } from '@/lib/utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { computeBadgeProgress } from '@/utils/walletBadges.utils'; + +interface BadgeProgressTrackerProps { + wallet: string; + className?: string; +} + +function formatEarnedDate(earnedAt: string): string { + const date = parseISO(earnedAt); + return isValid(date) ? format(date, 'MMM d, yyyy') : 'Date unavailable'; +} + +const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3'; + +/** + * Badge criteria progress tracker for the portfolio page (#1225). + * + * Unearned badges show their criteria and a progress bar measured against the + * wallet's activity metrics; earned badges show a green check and earn date. + * The section is hidden once every badge has been earned. Progress refreshes + * on a poll and whenever the wallet's holdings change (a new trade or hold). + */ +export default function BadgeProgressTracker({ + wallet, + className, +}: BadgeProgressTrackerProps) { + const badgesQuery = useWalletBadges(wallet); + const metricsQuery = useWalletBadgeMetrics(wallet); + const { dataUpdatedAt: holdingsUpdatedAt } = useWalletHoldings(wallet); + + const { refetch: refetchBadges } = badgesQuery; + const { refetch: refetchMetrics } = metricsQuery; + const lastHoldingsUpdateRef = useRef(holdingsUpdatedAt); + useEffect(() => { + const previous = lastHoldingsUpdateRef.current; + lastHoldingsUpdateRef.current = holdingsUpdatedAt; + // Skip the first holdings load; only react to later trade/hold refreshes. + if (!previous || holdingsUpdatedAt === previous) return; + void refetchBadges(); + void refetchMetrics(); + }, [holdingsUpdatedAt, refetchBadges, refetchMetrics]); + + const { earned, inProgress } = useMemo( + () => computeBadgeProgress(badgesQuery.data ?? [], metricsQuery.data), + [badgesQuery.data, metricsQuery.data] + ); + + const isLoading = badgesQuery.isLoading || metricsQuery.isLoading; + const isError = badgesQuery.isError || metricsQuery.isError; + + if (!isLoading && !isError && inProgress.length === 0) return null; + + return ( +
+
+

+ Badge Progress +

+

+ How close this wallet is to earning each badge. +

+
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, index) => ( + + ))} +
+ ) : isError ? ( +
+ Couldn't load badge progress. + +
+ ) : ( +
+ {inProgress.map(badge => { + const Icon = badge.icon; + const percent = Math.round(badge.percent); + return ( +
+
+
+
+
+ + {formatNumber(badge.current)} / {formatNumber(badge.target)}{' '} + {badge.unit} + + {percent}% +
+ +
+
+ ); + })} + + {earned.map(badge => ( +
+ +
+

{badge.name}

+

+ Earned {formatEarnedDate(badge.earnedAt)} +

+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/common/CreatorActivityFeed.tsx b/src/components/common/CreatorActivityFeed.tsx index 6d58e067..f9c5d410 100644 --- a/src/components/common/CreatorActivityFeed.tsx +++ b/src/components/common/CreatorActivityFeed.tsx @@ -1,6 +1,17 @@ -import { ArrowDownRight, ArrowUpRight, History } from 'lucide-react'; +import { + ArrowDownRight, + ArrowUpRight, + Coins, + History, + Send, + type LucideIcon, +} from 'lucide-react'; +import ActionAttributionLabel from '@/components/common/ActionAttributionLabel'; import { useCreatorActivityFeed } from '@/hooks/useCreatorActivityFeed'; -import { creatorActivityService } from '@/services/creatorActivity.service'; +import { + creatorActivityService, + type CreatorActivityType, +} from '@/services/creatorActivity.service'; import { useRelativeTime } from '@/hooks/useRelativeTime'; import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; @@ -10,12 +21,33 @@ export interface CreatorActivityFeedProps { const SKELETON_ROW_COUNT = 3; +const ACTIVITY_TYPE_CONFIG: Record< + CreatorActivityType, + { label: string; icon: LucideIcon; iconClass: string } +> = { + buy: { label: 'Buy', icon: ArrowUpRight, iconClass: 'text-emerald-400' }, + sell: { label: 'Sell', icon: ArrowDownRight, iconClass: 'text-rose-400' }, + royalty_claim: { + label: 'Royalty claim', + icon: Coins, + iconClass: 'text-amber-300', + }, + revenue_distribution: { + label: 'Revenue distribution', + icon: Send, + iconClass: 'text-sky-300', + }, +}; + interface ActivityRowProps { trade: ReturnType['trades'][number]; } function ActivityRow({ trade }: ActivityRowProps) { const relativeTime = useRelativeTime(trade.timestamp); + const config = ACTIVITY_TYPE_CONFIG[trade.type] ?? ACTIVITY_TYPE_CONFIG.buy; + const Icon = config.icon; + const isTrade = trade.type === 'buy' || trade.type === 'sell'; return (
- {trade.type === 'buy' ? ( - - ) : ( - - )} +
-
- - {trade.type === 'buy' ? 'Buy' : 'Sell'} - - • - - {formatCreatorHandle(trade.traderHandle)} - +
+ {config.label} + {isTrade && ( + <> + • + + {formatCreatorHandle(trade.traderHandle)} + + + )} + {trade.actor && ( + + )}
- {trade.amount} keys - • - {trade.price} XLM + {isTrade ? ( + <> + {trade.amount} keys + • + {trade.price} XLM + + ) : ( + {trade.amount} XLM + )} • {relativeTime}
diff --git a/src/components/common/__tests__/ActionAttributionLabel.test.tsx b/src/components/common/__tests__/ActionAttributionLabel.test.tsx new file mode 100644 index 00000000..9b4366b1 --- /dev/null +++ b/src/components/common/__tests__/ActionAttributionLabel.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ActionAttributionLabel from '@/components/common/ActionAttributionLabel'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; + +vi.mock('@/utils/clipboard.utils', () => ({ + copyTextToClipboard: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('@/utils/toast.util', () => ({ + default: { success: vi.fn(), error: vi.fn() }, +})); + +const WALLET = 'GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI'; + +describe('ActionAttributionLabel', () => { + it('shows a Creator label for primary creator actions', () => { + render( + + ); + expect(screen.getByText('Posted by')).toBeInTheDocument(); + expect(screen.getByTestId('action-attribution-role')).toHaveTextContent('Creator'); + expect(screen.getByTestId('action-attribution-wallet')).toHaveTextContent('GBZX…MADI'); + }); + + it('shows the role badge for co-creator actions', () => { + render(); + expect(screen.getByTestId('action-attribution')).toHaveAttribute('data-role', 'Revenue'); + expect(screen.getByTestId('action-attribution-role')).toHaveTextContent('Revenue'); + }); + + it('reveals the full address and copies it', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('action-attribution-wallet')); + expect(await screen.findByTestId('action-attribution-full-address')).toHaveTextContent( + WALLET + ); + expect(screen.getByText('Admin co-creator wallet')).toBeInTheDocument(); + + await user.click(screen.getByTestId('action-attribution-copy')); + expect(copyTextToClipboard).toHaveBeenCalledWith(WALLET); + await waitFor(() => + expect(screen.getByTestId('action-attribution-copy')).toHaveAttribute( + 'aria-label', + 'Address copied' + ) + ); + }); +}); diff --git a/src/components/common/__tests__/AnnouncementFeed.test.tsx b/src/components/common/__tests__/AnnouncementFeed.test.tsx index 19667777..baa67de8 100644 --- a/src/components/common/__tests__/AnnouncementFeed.test.tsx +++ b/src/components/common/__tests__/AnnouncementFeed.test.tsx @@ -10,6 +10,7 @@ */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import AnnouncementFeed from '@/components/common/AnnouncementFeed'; import AnnouncementItem from '@/components/common/AnnouncementItem'; import { @@ -163,3 +164,42 @@ describe('AnnouncementFeed (#1175)', () => { expect(mockFetchPage).toHaveBeenCalledWith('creator-1', 2, 1); }); }); + +describe('AnnouncementItem co-creator attribution (#1224)', () => { + const WALLET = 'GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI'; + + function renderItem(announcement: Announcement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); + } + + it('shows the posting co-creator wallet and role badge', () => { + renderItem( + makeAnnouncement({ + postedBy: { wallet: WALLET, role: 'Content' }, + }) + ); + const label = screen.getByTestId('action-attribution'); + expect(label).toHaveTextContent('Posted by'); + expect(label).toHaveTextContent('GBZX…MADI'); + expect(screen.getByTestId('action-attribution-role')).toHaveTextContent('Content'); + }); + + it('shows the Creator label for primary-creator posts', () => { + renderItem( + makeAnnouncement({ + postedBy: { wallet: WALLET, role: 'creator' }, + }) + ); + expect(screen.getByTestId('action-attribution-role')).toHaveTextContent('Creator'); + }); + + it('omits the label when the post has no attribution', () => { + renderItem(makeAnnouncement()); + expect(screen.queryByTestId('action-attribution')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/BadgeProgressTracker.test.tsx b/src/components/common/__tests__/BadgeProgressTracker.test.tsx new file mode 100644 index 00000000..633f23ed --- /dev/null +++ b/src/components/common/__tests__/BadgeProgressTracker.test.tsx @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import BadgeProgressTracker from '@/components/common/BadgeProgressTracker'; +import { + useWalletBadgeMetrics, + useWalletBadges, + useWalletHoldings, +} from '@/hooks/useWallet'; +import { + BADGE_CATALOG, + type WalletBadge, + type WalletBadgeMetrics, +} from '@/utils/walletBadges.utils'; + +vi.mock('@/hooks/useWallet', () => ({ + useWalletBadges: vi.fn(), + useWalletBadgeMetrics: vi.fn(), + useWalletHoldings: vi.fn(), +})); + +const WALLET = '0x1111111111111111111111111111111111111111'; +const refetchBadges = vi.fn(); +const refetchMetrics = vi.fn(); + +function mockQueries({ + badges = [], + metrics = {}, + holdingsUpdatedAt = 1, + isLoading = false, + isError = false, +}: { + badges?: WalletBadge[]; + metrics?: Partial; + holdingsUpdatedAt?: number; + isLoading?: boolean; + isError?: boolean; +}) { + vi.mocked(useWalletBadges).mockReturnValue({ + data: isLoading ? undefined : badges, + isLoading, + isError, + refetch: refetchBadges, + } as unknown as ReturnType); + vi.mocked(useWalletBadgeMetrics).mockReturnValue({ + data: isLoading ? undefined : metrics, + isLoading, + isError: false, + refetch: refetchMetrics, + } as unknown as ReturnType); + vi.mocked(useWalletHoldings).mockReturnValue({ + dataUpdatedAt: holdingsUpdatedAt, + } as unknown as ReturnType); +} + +describe('BadgeProgressTracker', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders unearned badges with criteria and accurate progress', () => { + mockQueries({ metrics: { tradeVolumeXlm: 2500, tradeCount: 40 } }); + render(); + + const whale = screen.getByTestId('badge-progress-whale'); + expect(whale).toHaveTextContent('Whale'); + expect(whale).toHaveTextContent('Trade 10,000 XLM in creator key volume'); + expect(screen.getByTestId('badge-progress-whale-value')).toHaveTextContent( + '2,500 / 10,000 XLM' + ); + expect(screen.getByTestId('badge-progress-whale-bar')).toHaveAttribute( + 'aria-valuenow', + '25' + ); + expect(screen.getByTestId('badge-progress-top-trader-bar')).toHaveAttribute( + 'aria-valuenow', + '40' + ); + }); + + it('shows earned badges with a check and earn date', () => { + mockQueries({ + badges: [{ type: 'first-key', earnedAt: '2026-09-01T12:00:00Z' }], + }); + render(); + + const earned = screen.getByTestId('badge-progress-earned-first-key'); + expect(earned).toHaveTextContent('First Key'); + expect(earned).toHaveTextContent('Earned Sep 1, 2026'); + expect(screen.getByLabelText('Earned')).toBeInTheDocument(); + expect(screen.queryByTestId('badge-progress-first-key')).not.toBeInTheDocument(); + }); + + it('hides the section when every badge is earned', () => { + mockQueries({ + badges: BADGE_CATALOG.map(b => ({ type: b.id, earnedAt: '2026-09-01T00:00:00Z' })), + }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows a loading skeleton while data loads', () => { + mockQueries({ isLoading: true }); + render(); + expect(screen.getByTestId('badge-progress-loading')).toBeInTheDocument(); + }); + + it('refetches progress when holdings change after a trade', () => { + mockQueries({ holdingsUpdatedAt: 1000 }); + const { rerender } = render(); + expect(refetchBadges).not.toHaveBeenCalled(); + expect(refetchMetrics).not.toHaveBeenCalled(); + + mockQueries({ holdingsUpdatedAt: 2000 }); + rerender(); + expect(refetchBadges).toHaveBeenCalledTimes(1); + expect(refetchMetrics).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/common/__tests__/CreatorActivityFeed.test.tsx b/src/components/common/__tests__/CreatorActivityFeed.test.tsx index ababee82..7eb9fe7c 100644 --- a/src/components/common/__tests__/CreatorActivityFeed.test.tsx +++ b/src/components/common/__tests__/CreatorActivityFeed.test.tsx @@ -107,4 +107,51 @@ describe('CreatorActivityFeed', () => { expect(screen.getByTestId('creator-activity-item-a')).toHaveTextContent('Buy'); expect(screen.getByTestId('creator-activity-item-b')).toHaveTextContent('Sell'); }); + + it('attributes royalty claims and revenue distributions to the acting co-creator (#1224)', () => { + mockUseCreatorActivityFeed.mockReturnValue({ + trades: [ + makeTrade('r', { + type: 'royalty_claim', + amount: 42, + actor: { wallet: 'GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI', role: 'Revenue' }, + }), + makeTrade('d', { + type: 'revenue_distribution', + amount: 100, + actor: { wallet: 'GCREATORWALLET0000000000000000000000000000000000000ABCD', role: 'creator' }, + }), + ], + isLoading: false, + isError: false, + }); + + render(); + + const claim = screen.getByTestId('creator-activity-item-r'); + expect(claim).toHaveTextContent('Royalty claim'); + expect(claim).toHaveTextContent('42 XLM'); + expect(claim).toHaveTextContent('GBZX…MADI'); + expect(claim.querySelector('[data-testid="action-attribution-role"]')).toHaveTextContent( + 'Revenue' + ); + + const distribution = screen.getByTestId('creator-activity-item-d'); + expect(distribution).toHaveTextContent('Revenue distribution'); + expect( + distribution.querySelector('[data-testid="action-attribution-role"]') + ).toHaveTextContent('Creator'); + }); + + it('omits attribution for entries without an acting wallet', () => { + mockUseCreatorActivityFeed.mockReturnValue({ + trades: [makeTrade('a')], + isLoading: false, + isError: false, + }); + + render(); + + expect(screen.queryByTestId('action-attribution')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/marketplace/CampaignCard.tsx b/src/components/marketplace/CampaignCard.tsx new file mode 100644 index 00000000..2809ec65 --- /dev/null +++ b/src/components/marketplace/CampaignCard.tsx @@ -0,0 +1,128 @@ +import { Link } from 'react-router'; +import { BadgeCheck, Clock, Gift } from 'lucide-react'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import { + describeCampaignCriteria, + getCampaignEligibility, + type Campaign, +} from '@/utils/campaign.utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { formatSpotlightTimeRemaining } from '@/utils/spotlightAuction.utils'; + +interface CampaignCardProps { + campaign: Campaign; + nowMs: number; + /** Hides the progress bar when no wallet is connected. */ + showProgress: boolean; +} + +/** One seasonal campaign with criteria, reward pool, countdown and eligibility (#1226). */ +export default function CampaignCard({ + campaign, + nowMs, + showProgress, +}: CampaignCardProps) { + const timeRemaining = + formatSpotlightTimeRemaining(campaign.endsAt, nowMs) ?? 'Ended'; + const eligibility = getCampaignEligibility( + campaign.walletProgress, + campaign.criteria.target + ); + const progressPercent = Math.round(eligibility.percent); + + return ( +
+
+
+ + {campaign.keyName} + +

+ {campaign.name} +

+
+ {showProgress && eligibility.eligible && ( + + + )} +
+ +

+ {describeCampaignCriteria(campaign.criteria, campaign.keyName)} +

+ +
+
+
+
+
+ {formatNumber(campaign.rewardPoolXlm)} XLM +
+
+
+
+
+
+ {timeRemaining} +
+
+
+ + {showProgress && ( +
+
+ Your progress + + {formatNumber(eligibility.current)} /{' '} + {formatNumber(eligibility.target)} + +
+ div]:bg-emerald-400' + )} + /> +
+ )} +
+ ); +} diff --git a/src/components/marketplace/CampaignsSection.tsx b/src/components/marketplace/CampaignsSection.tsx new file mode 100644 index 00000000..3070ef9c --- /dev/null +++ b/src/components/marketplace/CampaignsSection.tsx @@ -0,0 +1,74 @@ +import { Link } from 'react-router'; +import { useAccount } from 'wagmi'; +import { ArrowRight, Trophy } from 'lucide-react'; +import CampaignCard from '@/components/marketplace/CampaignCard'; +import { useActiveCampaigns } from '@/hooks/useCampaigns'; +import { useNowMs } from '@/hooks/useNowMs'; +import { filterActiveCampaigns } from '@/utils/campaign.utils'; + +/** Campaigns previewed on the discovery page; the rest live on /campaigns. */ +export const DISCOVERY_CAMPAIGN_LIMIT = 3; + +/** + * Seasonal campaign eligibility tracker for the discovery page (#1226). + * + * Lists active campaigns with their criteria, reward pool, a live countdown + * and the connected wallet's progress. Renders nothing while loading, on + * error, or when no campaign is still running. + */ +export default function CampaignsSection() { + const { address } = useAccount(); + const { data } = useActiveCampaigns(address); + const nowMs = useNowMs(); + + const campaigns = filterActiveCampaigns(data ?? [], nowMs); + if (campaigns.length === 0) return null; + + return ( +
+
+
+
+
+

+ Campaigns +

+

+ Meet a campaign's criteria before it ends to share its reward pool +

+
+ + + View All Campaigns +
+ +
+ {campaigns.slice(0, DISCOVERY_CAMPAIGN_LIMIT).map(campaign => ( + + ))} +
+
+ ); +} diff --git a/src/components/marketplace/SpotlightBanner.tsx b/src/components/marketplace/SpotlightBanner.tsx new file mode 100644 index 00000000..8def67fe --- /dev/null +++ b/src/components/marketplace/SpotlightBanner.tsx @@ -0,0 +1,152 @@ +import { Link } from 'react-router'; +import { ArrowRight, Clock, Star } from 'lucide-react'; +import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; +import { useActiveSpotlight } from '@/hooks/useSpotlightAuction'; +import { useNowMs } from '@/hooks/useNowMs'; +import { cn } from '@/lib/utils'; +import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; +import { + formatHolderCount, + formatNumber, + formatXlmPrice, +} from '@/utils/numberFormat.utils'; +import { formatSpotlightTimeRemaining } from '@/utils/spotlightAuction.utils'; + +interface SpotlightBannerProps { + className?: string; +} + +interface SpotlightStatProps { + label: string; + value: string; + testId: string; +} + +function SpotlightStat({ label, value, testId }: SpotlightStatProps) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +/** + * Premium spotlight banner for the marketplace discovery surfaces (#1219). + * + * Shows the key currently holding the paid spotlight placement with its live + * price, 24h volume, holder count and a ticking countdown to the end of the + * placement. Renders nothing while loading, on error, when no placement is + * active, or once the countdown reaches zero. + */ +export default function SpotlightBanner({ className }: SpotlightBannerProps) { + const { data: spotlight } = useActiveSpotlight(); + const nowMs = useNowMs(); + + const timeRemaining = formatSpotlightTimeRemaining(spotlight?.endsAt, nowMs); + if (!spotlight || !timeRemaining) return null; + + const handle = spotlight.creatorHandle + ? formatCreatorHandle(spotlight.creatorHandle) + : null; + + return ( +
+
+
+
+ +
+
+
+ + + + {spotlight.sponsorLabel || 'Sponsored'} + +
+

+ {spotlight.keyName} +

+

+ by {spotlight.creatorName} + {handle && · {handle}} +

+
+
+ +
+ + + +
+
+
+
+ {timeRemaining} +
+
+
+ + + Buy Now +
+
+ ); +} diff --git a/src/components/marketplace/__tests__/CampaignsSection.test.tsx b/src/components/marketplace/__tests__/CampaignsSection.test.tsx new file mode 100644 index 00000000..8b1afa58 --- /dev/null +++ b/src/components/marketplace/__tests__/CampaignsSection.test.tsx @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { useAccount } from 'wagmi'; +import CampaignsSection from '@/components/marketplace/CampaignsSection'; +import { useActiveCampaigns } from '@/hooks/useCampaigns'; +import type { Campaign } from '@/utils/campaign.utils'; + +vi.mock('@/hooks/useCampaigns', () => ({ + useActiveCampaigns: vi.fn(), +})); + +const mockUseActiveCampaigns = vi.mocked(useActiveCampaigns); +const mockUseAccount = vi.mocked(useAccount); +const NOW = Date.parse('2026-10-20T12:00:00Z'); +const WALLET = '0x1111111111111111111111111111111111111111'; + +function makeCampaign(id: string, overrides: Partial = {}): Campaign { + return { + id, + name: `Season ${id}`, + creatorId: `creator-${id}`, + keyName: 'NOVA', + criteria: { type: 'hold_keys', target: 10 }, + rewardPoolXlm: 5000, + endsAt: new Date(NOW + 60 * 60 * 1000).toISOString(), + walletProgress: 4, + ...overrides, + }; +} + +function mockCampaigns(data: Campaign[] | undefined) { + mockUseActiveCampaigns.mockReturnValue({ + data, + } as unknown as ReturnType); +} + +function connectWallet(address: string | undefined) { + mockUseAccount.mockReturnValue({ + address, + isConnected: Boolean(address), + } as unknown as ReturnType); +} + +function renderSection() { + return render( + + + + ); +} + +describe('CampaignsSection', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + connectWallet(WALLET); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders criteria, reward pool and wallet progress', () => { + mockCampaigns([makeCampaign('a')]); + renderSection(); + + const card = screen.getByTestId('campaign-card-a'); + expect(within(card).getByTestId('campaign-criteria')).toHaveTextContent( + 'Hold 10 NOVA keys' + ); + expect(within(card).getByTestId('campaign-reward-pool')).toHaveTextContent( + '5,000 XLM' + ); + expect(within(card).getByTestId('campaign-progress-label')).toHaveTextContent( + '4 / 10' + ); + expect(within(card).getByTestId('campaign-progress-bar')).toHaveAttribute( + 'aria-valuenow', + '40' + ); + expect(within(card).queryByTestId('campaign-eligible-badge')).not.toBeInTheDocument(); + }); + + it('shows the Eligible badge when the wallet meets the criteria', () => { + mockCampaigns([makeCampaign('a', { walletProgress: 10 })]); + renderSection(); + expect(screen.getByTestId('campaign-eligible-badge')).toHaveTextContent('Eligible'); + expect(screen.getByTestId('campaign-progress-bar')).toHaveAttribute( + 'aria-valuenow', + '100' + ); + }); + + it('hides progress and eligibility when no wallet is connected', () => { + connectWallet(undefined); + mockCampaigns([makeCampaign('a', { walletProgress: 10 })]); + renderSection(); + expect(screen.queryByTestId('campaign-progress-bar')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-eligible-badge')).not.toBeInTheDocument(); + }); + + it('scopes the campaigns query to the connected wallet', () => { + mockCampaigns([makeCampaign('a')]); + renderSection(); + expect(mockUseActiveCampaigns).toHaveBeenCalledWith(WALLET); + }); + + it('ticks the time remaining countdown', () => { + mockCampaigns([makeCampaign('a')]); + renderSection(); + expect(screen.getByTestId('campaign-time-remaining')).toHaveTextContent('1h 0m 0s'); + + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(screen.getByTestId('campaign-time-remaining')).toHaveTextContent('59m 58s'); + }); + + it('links to the full campaigns page and previews at most three cards', () => { + mockCampaigns(['a', 'b', 'c', 'd'].map(id => makeCampaign(id))); + renderSection(); + expect(screen.getByTestId('view-all-campaigns')).toHaveAttribute('href', '/campaigns'); + expect(screen.getAllByRole('article')).toHaveLength(3); + }); + + it('renders nothing when no campaigns are active', () => { + mockCampaigns([makeCampaign('old', { endsAt: new Date(NOW - 1000).toISOString() })]); + const { container } = renderSection(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing while campaigns are still loading', () => { + mockCampaigns(undefined); + const { container } = renderSection(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/marketplace/__tests__/SpotlightBanner.test.tsx b/src/components/marketplace/__tests__/SpotlightBanner.test.tsx new file mode 100644 index 00000000..b94cd3c0 --- /dev/null +++ b/src/components/marketplace/__tests__/SpotlightBanner.test.tsx @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import SpotlightBanner from '@/components/marketplace/SpotlightBanner'; +import { useActiveSpotlight } from '@/hooks/useSpotlightAuction'; +import type { ActiveSpotlight } from '@/services/spotlightAuction.service'; + +vi.mock('@/hooks/useSpotlightAuction', () => ({ + useActiveSpotlight: vi.fn(), +})); + +const mockUseActiveSpotlight = vi.mocked(useActiveSpotlight); +const NOW = Date.parse('2026-10-20T12:00:00Z'); + +function makeSpotlight(overrides: Partial = {}): ActiveSpotlight { + return { + creatorId: 'creator-42', + keyName: 'NOVA', + creatorName: 'Nova Studio', + creatorHandle: '@nova', + priceXlm: 12.5, + volume24hXlm: 15_300, + holderCount: 1240, + endsAt: new Date(NOW + 2 * 60 * 60 * 1000 + 5_000).toISOString(), + ...overrides, + }; +} + +function mockSpotlight(data: ActiveSpotlight | null | undefined) { + mockUseActiveSpotlight.mockReturnValue({ + data, + } as unknown as ReturnType); +} + +function renderBanner() { + return render( + + + + ); +} + +describe('SpotlightBanner', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the spotlighted key stats', () => { + mockSpotlight(makeSpotlight()); + renderBanner(); + + expect(screen.getByTestId('spotlight-key-name')).toHaveTextContent('NOVA'); + expect(screen.getByTestId('spotlight-creator')).toHaveTextContent('by Nova Studio'); + expect(screen.getByTestId('spotlight-creator')).toHaveTextContent('nova'); + expect(screen.getByTestId('spotlight-price')).toHaveTextContent('12.50 XLM'); + expect(screen.getByTestId('spotlight-volume')).toHaveTextContent('15.3K XLM'); + expect(screen.getByTestId('spotlight-holders')).toHaveTextContent('1.2K'); + }); + + it('shows the default sponsor label, or the API-provided one', () => { + mockSpotlight(makeSpotlight()); + const { unmount } = renderBanner(); + expect(screen.getByTestId('spotlight-sponsor-label')).toHaveTextContent('Sponsored'); + unmount(); + + mockSpotlight(makeSpotlight({ sponsorLabel: 'Paid placement' })); + renderBanner(); + expect(screen.getByTestId('spotlight-sponsor-label')).toHaveTextContent( + 'Paid placement' + ); + }); + + it('links Buy Now to the key detail page', () => { + mockSpotlight(makeSpotlight()); + renderBanner(); + expect(screen.getByTestId('spotlight-buy-now')).toHaveAttribute( + 'href', + '/creator/creator-42' + ); + }); + + it('updates the countdown every second', () => { + mockSpotlight(makeSpotlight()); + renderBanner(); + expect(screen.getByTestId('spotlight-countdown')).toHaveTextContent('2h 0m 5s'); + + act(() => { + vi.advanceTimersByTime(3_000); + }); + expect(screen.getByTestId('spotlight-countdown')).toHaveTextContent('2h 0m 2s'); + }); + + it('hides itself once the spotlight ends', () => { + mockSpotlight(makeSpotlight({ endsAt: new Date(NOW + 2_000).toISOString() })); + renderBanner(); + expect(screen.getByTestId('spotlight-banner')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(3_000); + }); + expect(screen.queryByTestId('spotlight-banner')).not.toBeInTheDocument(); + }); + + it('renders nothing when there is no active spotlight', () => { + mockSpotlight(null); + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/hooks/useCampaigns.ts b/src/hooks/useCampaigns.ts new file mode 100644 index 00000000..46cf5b20 --- /dev/null +++ b/src/hooks/useCampaigns.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { campaignsService } from '@/services/campaigns.service'; + +// Campaign progress only moves on trades, so a minute keeps it fresh without +// competing with the discovery page's own 60s refresh. +const CAMPAIGNS_POLL_INTERVAL_MS = 60_000; + +/** Active seasonal campaigns, scoped to the connected wallet's progress (#1226). */ +export function useActiveCampaigns(wallet: string | undefined) { + return useQuery({ + queryKey: queryKeys.creators.discovery.campaigns(wallet), + queryFn: () => campaignsService.getActiveCampaigns(wallet), + refetchInterval: CAMPAIGNS_POLL_INTERVAL_MS, + }); +} diff --git a/src/hooks/useSpotlightAuction.ts b/src/hooks/useSpotlightAuction.ts index 1dc9fd2e..6cbbfaf4 100644 --- a/src/hooks/useSpotlightAuction.ts +++ b/src/hooks/useSpotlightAuction.ts @@ -26,3 +26,12 @@ export function useSpotlightBids(creatorId: string, wallet: string | undefined) refetchInterval: SPOTLIGHT_POLL_INTERVAL_MS, }); } + +/** The marketplace-wide spotlighted key, or null when no placement is live (#1219). */ +export function useActiveSpotlight() { + return useQuery({ + queryKey: queryKeys.creators.discovery.spotlight(), + queryFn: () => spotlightAuctionService.getActiveSpotlight(), + refetchInterval: SPOTLIGHT_POLL_INTERVAL_MS, + }); +} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index b9e03ae9..98adb6b7 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -13,10 +13,14 @@ import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { fetchWalletActivityPage } from '@/services/walletActivity.service'; import { + fetchWalletBadgeMetrics, fetchWalletBadges, fetchWalletHoldings, } from '@/services/wallet.service'; -import type { WalletBadge } from '@/utils/walletBadges.utils'; +import type { + WalletBadge, + WalletBadgeMetrics, +} from '@/utils/walletBadges.utils'; import { fetchTradeHistoryPage } from '@/services/tradeHistory.service'; export function useWalletHoldings(address: string) { @@ -35,6 +39,19 @@ export function useWalletBadges(address: string) { }); } +// Badge progress moves with every trade, so poll it like other live portfolio +// figures rather than waiting for a window refocus (#1225). +const BADGE_METRICS_POLL_INTERVAL_MS = 30_000; + +export function useWalletBadgeMetrics(address: string) { + return useQuery>({ + queryKey: queryKeys.wallet.badgeMetrics(address), + queryFn: () => fetchWalletBadgeMetrics(address), + enabled: !!address, + refetchInterval: BADGE_METRICS_POLL_INTERVAL_MS, + }); +} + /** * Paginated wallet activity feed. * diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 5ff85aee..35e74288 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -91,11 +91,16 @@ export const queryKeys = { all: () => ['creators', 'discovery'] as const, trending: () => ['creators', 'discovery', 'trending'] as const, newListings: () => ['creators', 'discovery', 'newListings'] as const, + spotlight: () => ['creators', 'discovery', 'spotlight'] as const, + campaigns: (wallet?: string) => + ['creators', 'discovery', 'campaigns', wallet ?? ''] as const, }, }, wallet: { holdings: (address: string) => ['wallet', address, 'holdings'] as const, badges: (address: string) => ['wallet', address, 'badges'] as const, + badgeMetrics: (address: string) => + ['wallet', address, 'badgeMetrics'] as const, portfolioHistory: (address: string, range: string) => ['wallet', address, 'portfolioHistory', range] as const, activity: (address: string) => ['wallet', address, 'activity'] as const, diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx new file mode 100644 index 00000000..72b6617e --- /dev/null +++ b/src/pages/CampaignsPage.tsx @@ -0,0 +1,69 @@ +import { useAccount } from 'wagmi'; +import CampaignCard from '@/components/marketplace/CampaignCard'; +import EmptyState from '@/components/common/EmptyState'; +import Skeleton from '@/components/ui/skeleton'; +import { useActiveCampaigns } from '@/hooks/useCampaigns'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { useNowMs } from '@/hooks/useNowMs'; +import { filterActiveCampaigns } from '@/utils/campaign.utils'; + +/** Full list of active seasonal campaigns, linked from the discovery page (#1226). */ +export default function CampaignsPage() { + useDocumentTitle('Campaigns — AccessLayer'); + const { address } = useAccount(); + const { data, isLoading, isError, refetch } = useActiveCampaigns(address); + const nowMs = useNowMs(); + + const campaigns = filterActiveCampaigns(data ?? [], nowMs); + + return ( +
+
+
+

+ Campaigns +

+

+ Seasonal reward campaigns for creator keys. Track how close your + wallet is to qualifying before each campaign ends. +

+
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, index) => ( + + ))} +
+ ) : isError ? ( + void refetch() }} + className="mx-auto w-full max-w-xl" + /> + ) : campaigns.length === 0 ? ( + + ) : ( +
+ {campaigns.map(campaign => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/DiscoveryPage.tsx b/src/pages/DiscoveryPage.tsx index 94507ed8..b1c5219a 100644 --- a/src/pages/DiscoveryPage.tsx +++ b/src/pages/DiscoveryPage.tsx @@ -8,6 +8,8 @@ import { DiscoveryCardGridSkeleton } from '@/components/common/DiscoveryKeyCardS import EmptyState from '@/components/common/EmptyState'; import MarketplaceSection from '@/components/common/MarketplaceSection'; import SectionDivider from '@/components/common/SectionDivider'; +import SpotlightBanner from '@/components/marketplace/SpotlightBanner'; +import CampaignsSection from '@/components/marketplace/CampaignsSection'; import { Button } from '@/components/ui/button'; import { useBatchTradeStore, selectBatchItemCount } from '@/hooks/useBatchTradeStore'; @@ -20,6 +22,8 @@ import { useBatchTradeStore, selectBatchItemCount } from '@/hooks/useBatchTradeS * 3. Each key card displays avatar, name, price, and 24h change. * 4. "View all" links leading to the marketplace with pre-applied filters (sort=volume_desc and sort=newest). * 5. Automatic background refresh every 60 seconds without full page reload. + * 6. Spotlight banner for the paid placement above the key grids (#1219). + * 7. Seasonal campaigns with wallet eligibility below the spotlight (#1226). */ export default function DiscoveryPage() { useNavigationTiming('discovery'); @@ -110,6 +114,9 @@ export default function DiscoveryPage() {
+ + + {hasError ? ( + + + + diff --git a/src/pages/WalletPortfolioPage.tsx b/src/pages/WalletPortfolioPage.tsx index 08dc516c..0ccbb5ce 100644 --- a/src/pages/WalletPortfolioPage.tsx +++ b/src/pages/WalletPortfolioPage.tsx @@ -6,6 +6,7 @@ import EmptyState from '@/components/common/EmptyState'; import { Button } from '@/components/ui/button'; import { useBatchTradeStore, selectBatchItemCount } from '@/hooks/useBatchTradeStore'; import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; +import BadgeProgressTracker from '@/components/common/BadgeProgressTracker'; import Skeleton from '@/components/ui/skeleton'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useWalletHoldings } from '@/hooks/useWallet'; @@ -415,6 +416,10 @@ export default function WalletPortfolioPage() {
)} + + + +
); diff --git a/src/pages/__tests__/DiscoveryPage.integration.test.tsx b/src/pages/__tests__/DiscoveryPage.integration.test.tsx index 1610ca98..7a2bc769 100644 --- a/src/pages/__tests__/DiscoveryPage.integration.test.tsx +++ b/src/pages/__tests__/DiscoveryPage.integration.test.tsx @@ -7,6 +7,14 @@ import { useKeyDiscovery } from '@/hooks/useKeyDiscovery'; import type { Course } from '@/services/course.service'; vi.mock('@/hooks/useKeyDiscovery'); +// Spotlight (#1219) and campaigns (#1226) have their own component tests; +// stub their queries so the page renders without a QueryClient. +vi.mock('@/hooks/useSpotlightAuction', () => ({ + useActiveSpotlight: () => ({ data: null }), +})); +vi.mock('@/hooks/useCampaigns', () => ({ + useActiveCampaigns: () => ({ data: [] }), +})); const mockUseKeyDiscovery = vi.mocked(useKeyDiscovery); diff --git a/src/pages/__tests__/MarketplacePage.integration.test.tsx b/src/pages/__tests__/MarketplacePage.integration.test.tsx index 1d9e8981..61c07e7a 100644 --- a/src/pages/__tests__/MarketplacePage.integration.test.tsx +++ b/src/pages/__tests__/MarketplacePage.integration.test.tsx @@ -15,6 +15,9 @@ import type { Course } from '@/services/course.service'; vi.mock('@/hooks/useInfiniteCreatorMarketplace'); vi.mock('@/hooks/useInfiniteScroll'); +vi.mock('@/hooks/useSpotlightAuction', () => ({ + useActiveSpotlight: () => ({ data: null }), +})); vi.mock('@/components/common/CreatorCard', async () => { const React = await import('react'); diff --git a/src/pages/__tests__/WalletPortfolioPage.test.tsx b/src/pages/__tests__/WalletPortfolioPage.test.tsx index 5c779501..6e55492e 100644 --- a/src/pages/__tests__/WalletPortfolioPage.test.tsx +++ b/src/pages/__tests__/WalletPortfolioPage.test.tsx @@ -16,6 +16,11 @@ vi.mock('@/hooks/useWallet', () => ({ useWalletHoldings: (...args: unknown[]) => mockUseWalletHoldings(...args), })); +// Badge progress (#1225) is covered by BadgeProgressTracker's own tests. +vi.mock('@/components/common/BadgeProgressTracker', () => ({ + default: () => null, +})); + const mockUseCreatorPrices = vi.fn(); vi.mock('@/hooks/useCreatorPrices', () => ({ useCreatorPrices: (...args: unknown[]) => mockUseCreatorPrices(...args), diff --git a/src/routes.tsx b/src/routes.tsx index beace650..145b0155 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -34,6 +34,7 @@ import BundleDetailPage from './pages/BundleDetailPage'; import SearchResultsPage from './pages/SearchResultsPage'; import KeyDiscoveryFeedPage from './pages/KeyDiscoveryFeedPage'; import WalletPortfolioPage from './pages/WalletPortfolioPage'; +import CampaignsPage from './pages/CampaignsPage'; import AutoInvestDashboardPage from './pages/AutoInvestDashboardPage'; import AirdropClaimsPage from './pages/AirdropClaimsPage'; import ProtocolAnalyticsPage from './pages/ProtocolAnalyticsPage'; @@ -83,6 +84,11 @@ export const routes = [ path: '/discovery/feed', element: , }, + { + // Full list of seasonal campaigns linked from discovery (#1226). + path: '/campaigns', + element: , + }, { path: '/leaderboard', element: , diff --git a/src/services/announcements.service.ts b/src/services/announcements.service.ts index c6906aa8..0d50e5da 100644 --- a/src/services/announcements.service.ts +++ b/src/services/announcements.service.ts @@ -5,8 +5,12 @@ import { type Announcement, type AnnouncementPage, } from '@/utils/announcements.utils'; +import { + toActionAttribution, + type ActionAttributionDto, +} from '@/utils/actionAttribution.utils'; -interface AnnouncementDto { +interface AnnouncementDto extends ActionAttributionDto { id: string; title: string; category?: string; @@ -23,6 +27,7 @@ function toAnnouncement(dto: AnnouncementDto): Announcement { category: normalizeAnnouncementCategory(dto.category), publishedAt: dto.publishedAt ?? dto.published_at ?? new Date().toISOString(), summary: dto.summary ?? dto.excerpt ?? '', + postedBy: toActionAttribution(dto), }; } diff --git a/src/services/campaigns.service.ts b/src/services/campaigns.service.ts new file mode 100644 index 00000000..3c1f84a3 --- /dev/null +++ b/src/services/campaigns.service.ts @@ -0,0 +1,28 @@ +import { BaseApiService, type APIResponse } from './api.service'; +import type { Campaign } from '@/utils/campaign.utils'; + +type CampaignsPayload = Campaign[] | { campaigns?: Campaign[] }; + +class CampaignsService extends BaseApiService { + /** + * Active seasonal campaigns, with the wallet's progress when supplied (#1226). + * GET /campaigns/active?wallet= + */ + async getActiveCampaigns(wallet?: string): Promise { + try { + const response = await this.api.get>( + '/campaigns/active', + { params: wallet ? { wallet } : undefined } + ); + + const payload = response.data.data; + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.campaigns)) return payload.campaigns; + return []; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const campaignsService = new CampaignsService(); diff --git a/src/services/creatorActivity.service.ts b/src/services/creatorActivity.service.ts index 7b30ffc8..b6002216 100644 --- a/src/services/creatorActivity.service.ts +++ b/src/services/creatorActivity.service.ts @@ -1,30 +1,61 @@ // src/services/creatorActivity.service.ts import { BaseApiService, type APIResponse } from './api.service'; +import { + toActionAttribution, + type ActionAttribution, + type ActionAttributionDto, +} from '@/utils/actionAttribution.utils'; + +export type CreatorActivityType = + | 'buy' + | 'sell' + | 'royalty_claim' + | 'revenue_distribution'; /** - * A single trade entry on a creator's public activity feed. + * A single entry on a creator's public activity feed. * - * Mirrors the shape consumed by the existing `TransactionHistory` - * component so the feed can pass entries straight through. + * Trades mirror the shape consumed by the existing `TransactionHistory` + * component so the feed can pass entries straight through. Royalty claims + * and revenue distributions reuse `amount` as the XLM moved (#1224). */ export interface CreatorActivityTrade { id: string; - type: 'buy' | 'sell'; + type: CreatorActivityType; traderHandle: string; amount: number; price: number; timestamp: number; txHash: string; status: 'completed' | 'pending' | 'failed'; + /** Creator or co-creator who acted, for creator-side actions (#1224). */ + actor?: ActionAttribution | null; +} + +type CreatorActivityDto = Omit & + ActionAttributionDto; + +function toCreatorActivityTrade(dto: CreatorActivityDto): CreatorActivityTrade { + return { + id: dto.id, + type: dto.type, + traderHandle: dto.traderHandle, + amount: dto.amount, + price: dto.price, + timestamp: dto.timestamp, + txHash: dto.txHash, + status: dto.status, + actor: toActionAttribution(dto), + }; } class CreatorActivityService extends BaseApiService { async getCreatorActivity(creatorId: string): Promise { try { - const response = await this.api.get>( + const response = await this.api.get>( `/creators/${creatorId}/activity` ); - return response.data.data; + return (response.data.data ?? []).map(toCreatorActivityTrade); } catch (error) { throw this.handleError(error); } diff --git a/src/services/spotlightAuction.service.ts b/src/services/spotlightAuction.service.ts index d9e832f1..b52cbce1 100644 --- a/src/services/spotlightAuction.service.ts +++ b/src/services/spotlightAuction.service.ts @@ -29,7 +29,43 @@ export interface SpotlightBid { refundPending: number; } +/** The key currently holding the paid marketplace spotlight placement (#1219). */ +export interface ActiveSpotlight { + creatorId: string; + keyName: string; + creatorName: string; + creatorHandle?: string | null; + thumbnail?: string | null; + /** Current bonding-curve price in XLM. */ + priceXlm: number; + /** Trailing 24h trading volume in XLM. */ + volume24hXlm: number; + holderCount: number; + /** ISO timestamp when the spotlight placement ends. */ + endsAt: string; + /** Optional sponsor copy; the banner falls back to "Sponsored". */ + sponsorLabel?: string | null; +} + class SpotlightAuctionService extends BaseApiService { + /** + * Spotlighted key shown at the top of the marketplace (#1219). + * GET /marketplace/spotlight — resolves to null when no placement is active. + */ + async getActiveSpotlight(): Promise { + try { + const response = await this.api.get>( + '/marketplace/spotlight' + ); + const spotlight = response.data.data; + return spotlight?.creatorId ? spotlight : null; + } catch (error) { + const apiError = this.handleError(error); + if (apiError.status === 404) return null; + throw apiError; + } + } + /** * Current spotlight round for a creator (#1147). * GET /creators/:creatorId/spotlight/auction diff --git a/src/services/wallet.service.ts b/src/services/wallet.service.ts index 71a87809..33dc5387 100644 --- a/src/services/wallet.service.ts +++ b/src/services/wallet.service.ts @@ -1,6 +1,9 @@ import { BaseApiService, type APIResponse } from './api.service'; import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; -import type { WalletBadge } from '@/utils/walletBadges.utils'; +import type { + WalletBadge, + WalletBadgeMetrics, +} from '@/utils/walletBadges.utils'; export type WalletHolding = HeldKeyPosition; @@ -43,6 +46,26 @@ class WalletService extends BaseApiService { throw apiError; } } + + /** + * Activity counters badge criteria are measured against (#1225). + * GET /wallets/:address/badge-metrics + */ + async getBadgeMetrics( + address: string + ): Promise> { + try { + const response = await this.api.get< + APIResponse | null> + >(`/wallets/${address}/badge-metrics`); + return response.data.data ?? {}; + } catch (error) { + const apiError = this.handleError(error); + // Like badges, a missing endpoint means no recorded progress yet. + if (apiError.status === 404) return {}; + throw apiError; + } + } } export const walletService = new WalletService(); @@ -58,3 +81,9 @@ export async function fetchWalletBadges( ): Promise { return walletService.getBadges(address); } + +export async function fetchWalletBadgeMetrics( + address: string +): Promise> { + return walletService.getBadgeMetrics(address); +} diff --git a/src/utils/__tests__/actionAttribution.utils.test.ts b/src/utils/__tests__/actionAttribution.utils.test.ts new file mode 100644 index 00000000..9e206e99 --- /dev/null +++ b/src/utils/__tests__/actionAttribution.utils.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + isCoCreatorAction, + normalizeActorRole, + toActionAttribution, +} from '@/utils/actionAttribution.utils'; + +const WALLET = 'GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI'; + +describe('normalizeActorRole', () => { + it('matches co-creator roles case-insensitively', () => { + expect(normalizeActorRole('admin')).toBe('Admin'); + expect(normalizeActorRole(' REVENUE ')).toBe('Revenue'); + expect(normalizeActorRole('Content')).toBe('Content'); + }); + + it('falls back to creator for unknown or missing roles', () => { + expect(normalizeActorRole('creator')).toBe('creator'); + expect(normalizeActorRole('owner')).toBe('creator'); + expect(normalizeActorRole(undefined)).toBe('creator'); + }); +}); + +describe('toActionAttribution', () => { + it('reads nested actor and postedBy objects', () => { + expect(toActionAttribution({ actor: { wallet: WALLET, role: 'Admin' } })).toEqual({ + wallet: WALLET, + role: 'Admin', + }); + expect( + toActionAttribution({ postedBy: { wallet: WALLET, role: 'content' } }) + ).toEqual({ wallet: WALLET, role: 'Content' }); + }); + + it('reads flattened camelCase and snake_case fields', () => { + expect(toActionAttribution({ actorWallet: WALLET, actorRole: 'Revenue' })).toEqual({ + wallet: WALLET, + role: 'Revenue', + }); + expect(toActionAttribution({ actor_wallet: WALLET })).toEqual({ + wallet: WALLET, + role: 'creator', + }); + }); + + it('returns null when no acting wallet is present', () => { + expect(toActionAttribution(undefined)).toBeNull(); + expect(toActionAttribution({})).toBeNull(); + expect(toActionAttribution({ actor: { wallet: ' ', role: 'Admin' } })).toBeNull(); + }); +}); + +describe('isCoCreatorAction', () => { + it('is false only for the primary creator', () => { + expect(isCoCreatorAction({ wallet: WALLET, role: 'creator' })).toBe(false); + expect(isCoCreatorAction({ wallet: WALLET, role: 'Admin' })).toBe(true); + }); +}); diff --git a/src/utils/__tests__/campaign.utils.test.ts b/src/utils/__tests__/campaign.utils.test.ts new file mode 100644 index 00000000..9bab8ed9 --- /dev/null +++ b/src/utils/__tests__/campaign.utils.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + describeCampaignCriteria, + filterActiveCampaigns, + getCampaignEligibility, + type Campaign, +} from '@/utils/campaign.utils'; + +function makeCampaign(id: string, endsAt: string): Campaign { + return { + id, + name: `Campaign ${id}`, + creatorId: 'creator-1', + keyName: 'NOVA', + criteria: { type: 'hold_keys', target: 5 }, + rewardPoolXlm: 1000, + endsAt, + }; +} + +describe('describeCampaignCriteria', () => { + it('describes each criteria type with the key name', () => { + expect(describeCampaignCriteria({ type: 'hold_keys', target: 5 }, 'NOVA')).toBe( + 'Hold 5 NOVA keys' + ); + expect(describeCampaignCriteria({ type: 'hold_keys', target: 1 }, 'NOVA')).toBe( + 'Hold 1 NOVA key' + ); + expect( + describeCampaignCriteria({ type: 'trade_volume', target: 2500 }, 'NOVA') + ).toBe('Trade 2,500 XLM of NOVA volume'); + expect(describeCampaignCriteria({ type: 'trade_count', target: 10 }, 'NOVA')).toBe( + 'Complete 10 NOVA trades' + ); + }); +}); + +describe('getCampaignEligibility', () => { + it('computes partial progress', () => { + expect(getCampaignEligibility(2, 8)).toEqual({ + current: 2, + target: 8, + percent: 25, + eligible: false, + }); + }); + + it('marks the wallet eligible and clamps at 100%', () => { + expect(getCampaignEligibility(12, 8)).toMatchObject({ + percent: 100, + eligible: true, + }); + expect(getCampaignEligibility(8, 8).eligible).toBe(true); + }); + + it('treats missing or invalid progress as zero', () => { + expect(getCampaignEligibility(undefined, 8)).toMatchObject({ + current: 0, + percent: 0, + eligible: false, + }); + expect(getCampaignEligibility(Number.NaN, 8).current).toBe(0); + expect(getCampaignEligibility(-3, 8).current).toBe(0); + }); +}); + +describe('filterActiveCampaigns', () => { + it('drops ended and unparseable campaigns', () => { + const now = Date.parse('2026-10-20T12:00:00Z'); + const result = filterActiveCampaigns( + [ + makeCampaign('live', '2026-10-21T00:00:00Z'), + makeCampaign('ended', '2026-10-19T00:00:00Z'), + makeCampaign('bad', 'not-a-date'), + ], + now + ); + expect(result.map(c => c.id)).toEqual(['live']); + }); +}); diff --git a/src/utils/__tests__/walletBadgeProgress.utils.test.ts b/src/utils/__tests__/walletBadgeProgress.utils.test.ts new file mode 100644 index 00000000..7b98fd93 --- /dev/null +++ b/src/utils/__tests__/walletBadgeProgress.utils.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + BADGE_CATALOG, + BADGE_PROGRESS_CRITERIA, + computeBadgeProgress, +} from '@/utils/walletBadges.utils'; + +describe('computeBadgeProgress', () => { + it('defines progress criteria for every catalog badge', () => { + for (const badge of BADGE_CATALOG) { + expect(BADGE_PROGRESS_CRITERIA[badge.id].target).toBeGreaterThan(0); + } + }); + + it('measures volume toward Whale and trades toward Top Trader', () => { + const { inProgress } = computeBadgeProgress([], { + tradeVolumeXlm: 2500, + tradeCount: 40, + }); + const whale = inProgress.find(b => b.id === 'whale'); + const topTrader = inProgress.find(b => b.id === 'top-trader'); + expect(whale).toMatchObject({ current: 2500, target: 10_000, percent: 25 }); + expect(topTrader).toMatchObject({ current: 40, target: 100, percent: 40 }); + }); + + it('clamps progress at 100% and treats missing metrics as zero', () => { + const { inProgress } = computeBadgeProgress([], { + tradeVolumeXlm: 50_000, + tradeCount: Number.NaN, + }); + expect(inProgress.find(b => b.id === 'whale')?.percent).toBe(100); + expect(inProgress.find(b => b.id === 'top-trader')?.current).toBe(0); + expect(inProgress.find(b => b.id === 'staker')?.percent).toBe(0); + }); + + it('moves earned badges out of the in-progress list with their date', () => { + const { earned, inProgress } = computeBadgeProgress( + [{ type: 'first-key', earnedAt: '2026-09-01T00:00:00Z' }], + { keysBought: 3 } + ); + expect(earned).toEqual([ + expect.objectContaining({ id: 'first-key', earnedAt: '2026-09-01T00:00:00Z' }), + ]); + expect(inProgress.some(b => b.id === 'first-key')).toBe(false); + }); + + it('returns no in-progress badges when every badge is earned', () => { + const all = BADGE_CATALOG.map(b => ({ type: b.id, earnedAt: '2026-09-01T00:00:00Z' })); + expect(computeBadgeProgress(all, null).inProgress).toEqual([]); + }); +}); diff --git a/src/utils/actionAttribution.utils.ts b/src/utils/actionAttribution.utils.ts new file mode 100644 index 00000000..33c31da0 --- /dev/null +++ b/src/utils/actionAttribution.utils.ts @@ -0,0 +1,69 @@ +/** + * Who performed a creator-key action (#1224): the primary creator or one of + * the co-creators managed in the creator dashboard (#1202). + */ + +export type CoCreatorActorRole = 'Admin' | 'Content' | 'Revenue'; +export type ActionActorRole = 'creator' | CoCreatorActorRole; + +export interface ActionAttribution { + wallet: string; + role: ActionActorRole; +} + +/** Raw attribution fields as the API may send them, nested or flattened. */ +export interface ActionAttributionDto { + actor?: { wallet?: string | null; role?: string | null } | null; + postedBy?: { wallet?: string | null; role?: string | null } | null; + actorWallet?: string | null; + actor_wallet?: string | null; + actorRole?: string | null; + actor_role?: string | null; +} + +const CO_CREATOR_ROLES: readonly CoCreatorActorRole[] = [ + 'Admin', + 'Content', + 'Revenue', +]; + +export const ACTOR_ROLE_BADGE_CLASSES: Record = { + Admin: 'text-red-300 bg-red-400/10 border-red-400/20', + Content: 'text-blue-300 bg-blue-400/10 border-blue-400/20', + Revenue: 'text-green-300 bg-green-400/10 border-green-400/20', +}; + +/** Maps an API role string to a known actor role; unknown or empty → creator. */ +export function normalizeActorRole(value: unknown): ActionActorRole { + if (typeof value !== 'string') return 'creator'; + const trimmed = value.trim().toLowerCase(); + return ( + CO_CREATOR_ROLES.find(role => role.toLowerCase() === trimmed) ?? 'creator' + ); +} + +export function isCoCreatorAction(attribution: ActionAttribution): boolean { + return attribution.role !== 'creator'; +} + +/** + * Reads attribution from an API record, or null when no acting wallet is + * present (older records), so feeds can omit the label entirely. + */ +export function toActionAttribution( + dto: ActionAttributionDto | null | undefined +): ActionAttribution | null { + if (!dto) return null; + const nested = dto.actor ?? dto.postedBy; + const wallet = ( + nested?.wallet ?? + dto.actorWallet ?? + dto.actor_wallet ?? + '' + ).trim(); + if (!wallet) return null; + return { + wallet, + role: normalizeActorRole(nested?.role ?? dto.actorRole ?? dto.actor_role), + }; +} diff --git a/src/utils/announcements.utils.ts b/src/utils/announcements.utils.ts index ae9a822d..119325b5 100644 --- a/src/utils/announcements.utils.ts +++ b/src/utils/announcements.utils.ts @@ -1,3 +1,5 @@ +import type { ActionAttribution } from '@/utils/actionAttribution.utils'; + /** * Announcement category chips (#1175). * Each category has its own colour so the feed is scannable at a glance. @@ -54,6 +56,8 @@ export interface Announcement { publishedAt: string; /** Short preview shown when collapsed. */ summary: string; + /** Wallet that posted it, when the API attributes the post (#1224). */ + postedBy?: ActionAttribution | null; } export interface AnnouncementPage { diff --git a/src/utils/campaign.utils.ts b/src/utils/campaign.utils.ts new file mode 100644 index 00000000..66fee83c --- /dev/null +++ b/src/utils/campaign.utils.ts @@ -0,0 +1,76 @@ +import { formatNumber } from '@/utils/numberFormat.utils'; + +export type CampaignCriteriaType = 'hold_keys' | 'trade_volume' | 'trade_count'; + +export interface CampaignCriteria { + type: CampaignCriteriaType; + /** Amount the wallet must reach (keys, XLM volume or trades). */ + target: number; +} + +export interface Campaign { + id: string; + name: string; + creatorId: string; + keyName: string; + criteria: CampaignCriteria; + /** Total reward pool in XLM. */ + rewardPoolXlm: number; + /** ISO timestamp when the campaign closes. */ + endsAt: string; + /** Connected wallet's progress toward `criteria.target`; absent when signed out. */ + walletProgress?: number | null; +} + +export interface CampaignEligibility { + current: number; + target: number; + /** Completion from 0 to 100. */ + percent: number; + eligible: boolean; +} + +/** Human-readable criteria line for a campaign card (#1226). */ +export function describeCampaignCriteria( + criteria: CampaignCriteria, + keyName: string +): string { + const target = formatNumber(criteria.target); + switch (criteria.type) { + case 'hold_keys': + return `Hold ${target} ${keyName} ${criteria.target === 1 ? 'key' : 'keys'}`; + case 'trade_volume': + return `Trade ${target} XLM of ${keyName} volume`; + case 'trade_count': + return `Complete ${target} ${keyName} ${criteria.target === 1 ? 'trade' : 'trades'}`; + default: + return `Reach ${target} on ${keyName}`; + } +} + +/** Clamped eligibility progress for a wallet against a campaign target (#1226). */ +export function getCampaignEligibility( + walletProgress: number | null | undefined, + target: number +): CampaignEligibility { + const current = + walletProgress != null && Number.isFinite(walletProgress) && walletProgress > 0 + ? walletProgress + : 0; + if (!Number.isFinite(target) || target <= 0) { + return { current, target: 0, percent: 100, eligible: true }; + } + const percent = Math.min(100, Math.max(0, (current / target) * 100)); + return { current, target, percent, eligible: current >= target }; +} + +/** Drops campaigns whose end time has passed or is unparseable (#1226). */ +export function filterActiveCampaigns( + campaigns: readonly Campaign[], + nowMs: number +): Campaign[] { + return campaigns.filter(campaign => { + const endsAtMs = new Date(campaign.endsAt).getTime(); + return Number.isFinite(endsAtMs) && endsAtMs > nowMs; + }); +} diff --git a/src/utils/walletBadges.utils.ts b/src/utils/walletBadges.utils.ts index 52ed036e..1103e817 100644 --- a/src/utils/walletBadges.utils.ts +++ b/src/utils/walletBadges.utils.ts @@ -1,11 +1,22 @@ -import { Coins, Droplets, Key, Users, Vote, type LucideIcon } from 'lucide-react'; +import { + Coins, + Droplets, + Fish, + Key, + Trophy, + Users, + Vote, + type LucideIcon, +} from 'lucide-react'; export type WalletBadgeType = | 'first-key' | 'staker' | 'liquidity-provider' | 'governance-voter' - | 'referrer'; + | 'referrer' + | 'whale' + | 'top-trader'; export type WalletBadge = { type: WalletBadgeType; @@ -52,6 +63,18 @@ export const BADGE_CATALOG: readonly BadgeDefinition[] = [ criteria: 'Refer a wallet that completes its first trade', icon: Users, }, + { + id: 'whale', + name: 'Whale', + criteria: 'Trade 10,000 XLM in creator key volume', + icon: Fish, + }, + { + id: 'top-trader', + name: 'Top Trader', + criteria: 'Complete 100 creator key trades', + icon: Trophy, + }, ]; /** @@ -82,3 +105,71 @@ export function splitBadges(badges: readonly WalletBadge[]): { return { earned, locked }; } + +/** Wallet activity counters the badge criteria are measured against (#1225). */ +export type WalletBadgeMetrics = { + keysBought: number; + stakedKeys: number; + liquidityPositions: number; + governanceVotes: number; + qualifiedReferrals: number; + tradeVolumeXlm: number; + tradeCount: number; +}; + +export type BadgeMetric = keyof WalletBadgeMetrics; + +export type BadgeProgressCriteria = { + metric: BadgeMetric; + target: number; + /** Unit shown next to progress values, e.g. "XLM" or "trades". */ + unit: string; +}; + +export const BADGE_PROGRESS_CRITERIA: Record< + WalletBadgeType, + BadgeProgressCriteria +> = { + 'first-key': { metric: 'keysBought', target: 1, unit: 'keys' }, + staker: { metric: 'stakedKeys', target: 1, unit: 'keys staked' }, + 'liquidity-provider': { + metric: 'liquidityPositions', + target: 1, + unit: 'positions', + }, + 'governance-voter': { metric: 'governanceVotes', target: 1, unit: 'votes' }, + referrer: { metric: 'qualifiedReferrals', target: 1, unit: 'referrals' }, + whale: { metric: 'tradeVolumeXlm', target: 10_000, unit: 'XLM' }, + 'top-trader': { metric: 'tradeCount', target: 100, unit: 'trades' }, +}; + +export type BadgeProgress = BadgeDefinition & { + current: number; + target: number; + unit: string; + /** Completion from 0 to 100. */ + percent: number; +}; + +/** + * Measures each unearned badge against the wallet's activity metrics (#1225). + * Missing or invalid metrics count as zero and progress is clamped to 0–100. + * Earned badges keep the same ordering and dedupe rules as `splitBadges`. + */ +export function computeBadgeProgress( + badges: readonly WalletBadge[], + metrics: Partial | null | undefined +): { earned: EarnedBadge[]; inProgress: BadgeProgress[] } { + const { earned, locked } = splitBadges(badges); + + const inProgress = locked.map(definition => { + const { metric, target, unit } = BADGE_PROGRESS_CRITERIA[definition.id]; + const raw = metrics?.[metric]; + const current = + typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : 0; + const percent = Math.min(100, (current / target) * 100); + return { ...definition, current, target, unit, percent }; + }); + + return { earned, inProgress }; +}