diff --git a/NIP.md b/NIP.md
index 177f952d..e2f34faf 100644
--- a/NIP.md
+++ b/NIP.md
@@ -357,7 +357,7 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
### Campaign Moderation Labels
-Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on Discover (`/discover`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
+Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
#### Namespace
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 28835a17..f8cdc8fd 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -49,7 +49,6 @@ const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").the
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
-const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
@@ -169,7 +168,6 @@ export function AppRouter() {
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
}>
} />
- } />
} />
} />
} />
diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx
index 9ffee0e6..7720cffa 100644
--- a/src/components/TopNav.tsx
+++ b/src/components/TopNav.tsx
@@ -33,7 +33,7 @@ interface NavItem {
}
const NAV_ITEMS: NavItem[] = [
- { label: 'Discover', to: '/discover', icon: HandHeart },
+ { label: 'Support', to: '/campaigns/all', icon: HandHeart },
{ label: 'Organize', to: '/communities', icon: Users },
{ label: 'Pledge', to: '/pledges', icon: Megaphone },
];
diff --git a/src/components/discovery/DiscoverHero.tsx b/src/components/discovery/DiscoverHero.tsx
deleted file mode 100644
index d00219a7..00000000
--- a/src/components/discovery/DiscoverHero.tsx
+++ /dev/null
@@ -1,355 +0,0 @@
-import { useEffect, useMemo, useState } from 'react';
-import { Link } from 'react-router-dom';
-import { Globe2, HandHeart, PlusCircle, Users } from 'lucide-react';
-
-import { HeroGlobe, type GlobeMarkerKind } from '@/components/HeroGlobe';
-import { HeroAtmosphere } from '@/components/HeroAtmosphere';
-import { Button } from '@/components/ui/button';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useCampaigns } from '@/hooks/useCampaigns';
-import { useGlobalActivity } from '@/hooks/useGlobalActivity';
-import { useGlobalDonations } from '@/hooks/useGlobalDonations';
-import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
-import { HOPE_PALETTE } from '@/lib/hopePalette';
-import { searchCountry } from '@/lib/countries';
-import { getCoordinates } from '@/lib/coordinates';
-import { formatSatsShort } from '@/lib/formatCampaignAmount';
-import { cn } from '@/lib/utils';
-
-interface DiscoverHeroProps {
- className?: string;
-}
-
-interface GlobeMarker {
- key: string;
- lat: number;
- lng: number;
- label: string;
- kind: GlobeMarkerKind;
-}
-
-interface TickerStat {
- /** Stable React key. */
- id: string;
- /** Big number / value text. */
- value: string;
- /** Trailing label that describes what the number is. */
- label: string;
- /** Decorative leading icon. */
- icon: React.ReactNode;
-}
-
-/** Country code → lat/lng. Used to seed country-pulse markers. */
-function lookupCountryCoords(code: string) {
- const coords = getCoordinates(code);
- return coords ? { lat: coords.latitude, lng: coords.longitude } : null;
-}
-
-/**
- * Discover-page hero. The same hand-drawn `HeroGlobe` that anchors the
- * fundraising home page (`/`), but reframed: the globe is the
- * protagonist, three marker types sit on it at once — campaigns
- * (hearts), communities (rings), and country-pulse (warm dots) — and a
- * rotating stat ticker headlines what the network has done.
- *
- * Visual chrome:
- * - Slow hue drift through `HOPE_PALETTE` every ~8s (the page literally
- * pulses with hope).
- * - `HeroAtmosphere` carries the warm scrim + radial glow + sunrise rim,
- * same component the campaigns hero uses for crossfade.
- * - No background photo — Discover isn't selling any one campaign, so
- * the sphere reads against a soft secondary wash instead.
- */
-export function DiscoverHero({ className }: DiscoverHeroProps) {
- // ─── Data ──────────────────────────────────────────────────────────────
- const { data: campaigns } = useCampaigns({ limit: 60 });
- const { data: communities } = useDiscoverCommunities({ limit: 60 });
- const { data: activityByCountry } = useGlobalActivity();
- const { data: donations, isLoading: donationsLoading } = useGlobalDonations();
-
- // ─── Globe markers ─────────────────────────────────────────────────────
- // Layer three pin types. We dedupe primarily by country so the globe
- // never piles dozens of markers on top of each other — the goal is a
- // sparse, hopeful constellation, not a heatmap. Hearts win over rings
- // win over dots when the same country shows up in multiple sources.
- const markers = useMemo(() => {
- const out: GlobeMarker[] = [];
- const claimedCountries = new Set();
-
- // 1. Campaigns → hearts. Newest first; cap at 18 so they don't crowd.
- let heartCount = 0;
- for (const c of campaigns ?? []) {
- if (heartCount >= 18) break;
- if (!c.location) continue;
- const match = searchCountry(c.location);
- if (!match) continue;
- if (claimedCountries.has(match.country.code)) continue;
- const coords = getCoordinates(match.country.code);
- if (!coords) continue;
- claimedCountries.add(match.country.code);
- out.push({
- key: `campaign:${c.aTag}`,
- lat: coords.latitude,
- lng: coords.longitude,
- label: c.title,
- kind: 'campaign',
- });
- heartCount++;
- }
-
- // 2. Country-pulse dots — the trusted-stats country activity, sized
- // implicitly by the marker glyph. Cap at 28 so the back of the globe
- // doesn't bristle when it rotates into view.
- let pulseCount = 0;
- if (activityByCountry) {
- const sortedCodes = [...activityByCountry.entries()]
- .sort((a, b) => b[1] - a[1])
- .map(([code]) => code);
- for (const code of sortedCodes) {
- if (pulseCount >= 28) break;
- if (claimedCountries.has(code)) continue;
- const coords = lookupCountryCoords(code);
- if (!coords) continue;
- claimedCountries.add(code);
- out.push({
- key: `pulse:${code}`,
- lat: coords.lat,
- lng: coords.lng,
- label: `Active in ${code}`,
- kind: 'country-pulse',
- });
- pulseCount++;
- }
- }
-
- // 3. Community rings — only when we can geolocate one of the
- // moderators. Communities don't carry a location tag of their own,
- // so we use a small heuristic: spread the first N communities across
- // continents by scattering them on a stable hash. Keeps the layer
- // present without inventing coordinates we can't justify.
- //
- // To keep ourselves honest we cap this at 6 rings and never overwrite
- // a country that already has a campaign heart or pulse dot. If we
- // genuinely can't place any, we skip the layer.
- const scatter: Array<{ lat: number; lng: number }> = [
- { lat: 40.7, lng: -74.0 }, // Americas
- { lat: -23.5, lng: -46.6 }, // S. America
- { lat: 51.5, lng: -0.1 }, // Europe
- { lat: -1.3, lng: 36.8 }, // Africa
- { lat: 35.7, lng: 139.7 }, // E. Asia
- { lat: -33.9, lng: 151.2 }, // Oceania
- ];
- let ringCount = 0;
- for (const community of communities ?? []) {
- if (ringCount >= scatter.length) break;
- const slot = scatter[ringCount];
- out.push({
- key: `community:${community.aTag}`,
- lat: slot.lat,
- lng: slot.lng,
- label: community.name,
- kind: 'community',
- });
- ringCount++;
- }
-
- return out;
- }, [campaigns, communities, activityByCountry]);
-
- // ─── Hue drift ─────────────────────────────────────────────────────────
- // Cycle through the hopeful palette on a slow ~9s interval. We seed
- // HeroAtmosphere with a stable string per cycle so its crossfade logic
- // kicks in correctly between hues.
- const [hueIndex, setHueIndex] = useState(0);
- useEffect(() => {
- const id = window.setInterval(() => {
- setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
- }, 9_000);
- return () => window.clearInterval(id);
- }, []);
- const activeHue = HOPE_PALETTE[hueIndex];
- const atmosphereSeed = `discover-hue-${activeHue.name}`;
-
- // ─── Stat ticker ───────────────────────────────────────────────────────
- // Three rotating, immutable network-wide stats. We compute them
- // defensively — when the underlying query is still loading we surface
- // a small skeleton inside the ticker row instead of "0" so the page
- // doesn't lie about the network's scale.
- const stats = useMemo(() => {
- const items: TickerStat[] = [];
-
- if (donations && donations.totalSats > 0) {
- items.push({
- id: 'sats',
- value: formatSatsShort(donations.totalSats),
- label: `raised on-chain across ${donations.campaignCount.toLocaleString()} ${
- donations.campaignCount === 1 ? 'campaign' : 'campaigns'
- }`,
- icon: ,
- });
- }
- if (communities && communities.length > 0) {
- items.push({
- id: 'communities',
- value: communities.length.toLocaleString(),
- label: `${communities.length === 1 ? 'organization' : 'organizations'} gathering on Nostr`,
- icon: ,
- });
- }
- if (activityByCountry && activityByCountry.size > 0) {
- items.push({
- id: 'countries',
- value: activityByCountry.size.toLocaleString(),
- label: `${activityByCountry.size === 1 ? 'country' : 'countries'} posting today`,
- icon: ,
- });
- }
- return items;
- }, [donations, communities, activityByCountry]);
-
- // Auto-advance the ticker. Holds at the first slot until at least one
- // stat is known so the visitor doesn't see an empty pill.
- const [tickerIndex, setTickerIndex] = useState(0);
- useEffect(() => {
- if (stats.length <= 1) return;
- const id = window.setInterval(() => {
- setTickerIndex((i) => (i + 1) % stats.length);
- }, 4_000);
- return () => window.clearInterval(id);
- }, [stats.length]);
-
- const currentStat = stats[tickerIndex % Math.max(stats.length, 1)];
-
- return (
-
- {/* Atmosphere — same scrim + radial glow + sunrise rim used on
- `/`. Seeded by the active hue so the whole hero blooms together
- when the palette advances. */}
-
-
- {/* Globe — centered, dominant. Slight upward bias so the headline
- beneath has breathing room. */}
-
-
-
-
-
-
- {/* Readability scrim. Sits above the globe + atmosphere but below
- the foreground content so the headline / paragraph stay legible
- regardless of which hue the palette is currently cycling
- through. Top-down so the eye-line lands on the darkest pixels;
- we taper to transparent before the ticker pill so the CTAs and
- stat row underneath keep their warm wash. */}
-
-
- {/* Foreground content — headline above the sphere, ticker + CTAs
- below it. Uses the same `max-w-5xl` container as the rest of
- the Discover page so the hero never sprawls wider than the
- shelves beneath it. */}
-
-
-
- Discover
-
-
- The world,
- gathering.
-
-
- Campaigns, communities, and conversations from every corner of the
- globe. Backed by Bitcoin, broadcast on Nostr, owned by no one.
-
-
-
- {/* Spacer so the next block lands beneath the sphere. */}
-
-
- {/* Rotating stat ticker. The fixed min-height stops the layout
- from jumping as labels swap; the keyed inner span re-mounts on
- every change to trigger the fade-in transition. */}
-
-
- {/* CTAs — clean glass pills, same vocabulary as `/`. Two clear
- actions: start something (campaign creation), or browse the
- world map for inspiration. */}
-
-
-
-
-
-
- );
-}
diff --git a/src/hooks/useDiscoverFeed.ts b/src/hooks/useDiscoverFeed.ts
deleted file mode 100644
index f3abbb32..00000000
--- a/src/hooks/useDiscoverFeed.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useNostr } from '@nostrify/react';
-import { useInfiniteQuery } from '@tanstack/react-query';
-import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
-
-import { CAMPAIGN_KIND } from '@/lib/campaign';
-import { shouldHideFeedEvent } from '@/lib/feedUtils';
-
-const DISCOVER_PAGE_SIZE = 30;
-
-/**
- * Kinds surfaced in the mixed Discover feed:
- *
- * - **30223** — new campaign creations (addressable). When a campaign is
- * minted or revised it bubbles back to the top, the same way a new
- * Substack post would.
- * - **1111** — NIP-22 comments. We pull two slices: comments scoped to
- * countries (`#K = iso3166`) and comments scoped to communities
- * (`#K = 34550`). Together these are "posts from the world" + "voices
- * inside the communities".
- * - **36639** — Agora pledges (challenges / civic calls). Always
- * included because they're the most action-oriented funding signal.
- *
- * We deliberately *exclude* free-form kind 1 notes here — the Discover
- * page is the place to see content that's tagged to a real-world thread
- * (country, community, campaign), not the global text-note firehose. The
- * old plain feed still lives at `/feed`.
- */
-
-/** Tag scopes we accept on kind 1111 comments. */
-const COMMENT_K_SCOPES = ['iso3166', 'geo', '34550'];
-
-/** Aliases we accept on kind 36639 pledge `t` tags. */
-const ACTION_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
-
-/**
- * Apply Discover-specific filtering after relay fetch. Drops events that
- * `shouldHideFeedEvent` flags (mutes, content filters happen later) and
- * any 1111 comment that lacks a recognised scope tag, since relays may
- * over-return when we union filters.
- */
-function filterDiscoverEvents(events: NostrEvent[]): NostrEvent[] {
- return events
- .filter((event) => {
- if (shouldHideFeedEvent(event)) return false;
- if (event.kind === 1111) {
- const kTags = event.tags
- .filter(([n]) => n === 'k' || n === 'K')
- .map(([, v]) => v);
- return kTags.some((v) => COMMENT_K_SCOPES.includes(v));
- }
- return true;
- })
- .sort((a, b) => b.created_at - a.created_at);
-}
-
-/**
- * Public infinite feed for the Discover page. Streams together new
- * campaigns, world-tagged comments, community comments, and Agora
- * pledges, paginated by `created_at` cursor.
- *
- * Each page issues exactly one relay request (the union of all relevant
- * filters) to stay inside per-page rate budgets — the same pattern
- * `useWorldFeed` uses.
- *
- * Returns the standard `useInfiniteQuery` surface plus a flattened
- * `events` list for convenient consumption.
- */
-export function useDiscoverFeed(enabled = true) {
- const { nostr } = useNostr();
-
- const query = useInfiniteQuery({
- queryKey: ['discover-feed'],
- queryFn: async ({ pageParam, signal: querySignal }) => {
- const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
- const until = pageParam as number | undefined;
-
- const filters: NostrFilter[] = [
- // New / revised campaigns — addressable, so we lean on a small
- // limit and let the relay's natural newest-first ordering surface
- // recent edits. No `#k` scoping needed.
- {
- kinds: [CAMPAIGN_KIND],
- limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
- ...(until && { until }),
- },
- // Community + country-scoped comments.
- {
- kinds: [1111],
- '#K': COMMENT_K_SCOPES,
- limit: DISCOVER_PAGE_SIZE,
- ...(until && { until }),
- },
- // Agora pledges.
- {
- kinds: [36639],
- '#t': ACTION_T_ALIASES,
- limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
- ...(until && { until }),
- },
- ];
-
- const raw = await nostr.query(filters, { signal });
- const filtered = filterDiscoverEvents(raw);
- const page = filtered.slice(0, DISCOVER_PAGE_SIZE);
-
- const oldestTimestamp = page.length > 0
- ? page[page.length - 1].created_at
- : null;
-
- return {
- events: page,
- oldestTimestamp,
- totalFetched: filtered.length,
- };
- },
- initialPageParam: undefined as number | undefined,
- getNextPageParam: (lastPage) => {
- if (lastPage.totalFetched < DISCOVER_PAGE_SIZE || !lastPage.oldestTimestamp) {
- return undefined;
- }
- return lastPage.oldestTimestamp - 1;
- },
- enabled,
- staleTime: 30_000,
- placeholderData: (prev) => prev,
- });
-
- // Flatten + dedupe. Each addressable event may legitimately appear
- // across pages if a newer revision lands; we keep the newest version.
- const seen = new Set();
- const events: NostrEvent[] = [];
- for (const page of query.data?.pages ?? []) {
- for (const event of page.events) {
- if (seen.has(event.id)) continue;
- seen.add(event.id);
- events.push(event);
- }
- }
-
- return {
- events,
- isLoading: query.isPending,
- isFetchingNextPage: query.isFetchingNextPage,
- hasNextPage: query.hasNextPage,
- fetchNextPage: query.fetchNextPage,
- pageCount: query.data?.pages.length,
- };
-}
diff --git a/src/pages/DiscoverPage.tsx b/src/pages/DiscoverPage.tsx
deleted file mode 100644
index dfd8e6e8..00000000
--- a/src/pages/DiscoverPage.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useSeoMeta } from '@unhead/react';
-
-import { Feed } from '@/components/Feed';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-
-/** `/discover` — the Agora activity feed. */
-export function DiscoverPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
-
- useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: !!user });
-
- useSeoMeta({
- title: `Discover | ${config.appName}`,
- description: 'Campaigns, pledges, donations, and conversations happening on Agora.',
- });
-
- return ;
-}
-
-export default DiscoverPage;