-
- {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 (
+
+
+
+
+ {describeCampaignCriteria(campaign.criteria, campaign.keyName)}
+
+
+
+
+
+
+ Reward pool
+
+
+ {formatNumber(campaign.rewardPoolXlm)} XLM
+
+
+
+
+
+ Time left
+
+
+ {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 (
+
+
+
+
+
+
+ Seasonal Rewards
+
+
+
+ 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
+
+
+ {spotlight.sponsorLabel || 'Sponsored'}
+
+
+
+ {spotlight.keyName}
+
+
+ by {spotlight.creatorName}
+ {handle && · {handle} }
+
+
+
+
+
+
+
+
+
+
+
+ Ends in
+
+
+ {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 (
+
+
+
+
+ {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() {