+
+ )}
+
+ );
+}
diff --git a/src/hooks/useDiscoveryFilters.ts b/src/hooks/useDiscoveryFilters.ts
new file mode 100644
index 00000000..c1b81d33
--- /dev/null
+++ b/src/hooks/useDiscoveryFilters.ts
@@ -0,0 +1,198 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+
+import { useDebounce } from '@/hooks/useDebounce';
+import type { Nip50Sort } from '@/hooks/useNip50Search';
+
+/**
+ * Type-guard for the `?sort=` URL param value used by every discovery
+ * section (Campaigns, Groups, Pledges).
+ *
+ * - `'top'` and `'new'` map to the toolbar's active sort modes.
+ * - Anything else (missing, empty, legacy values) collapses to
+ * `'default'`, the curated featured-first idle state.
+ */
+function parseSort(value: string | null): Nip50Sort {
+ if (value === 'top') return 'top';
+ if (value === 'new') return 'new';
+ return 'default';
+}
+
+export interface DiscoveryFilters {
+ /**
+ * Live search input value, updated on every keystroke. Bind this to
+ * the toolbar's `` so typing stays responsive.
+ */
+ searchInput: string;
+ setSearchInput: (next: string) => void;
+ /**
+ * Debounced search value. Use this as the input to relay queries
+ * and as the source for "is this section actively searching?"
+ * checks. URL writes also happen on this value, so the URL doesn't
+ * churn on every keystroke.
+ */
+ debouncedSearch: string;
+ /** Active sort mode. */
+ sort: Nip50Sort;
+ setSort: (next: Nip50Sort) => void;
+ /** Selected ISO-3166 alpha-2 country code, or `undefined` for global. */
+ country: string | undefined;
+ setCountry: (next: string | undefined) => void;
+}
+
+interface UseDiscoveryFiltersOptions {
+ /**
+ * URL-namespace for persisted filters, or `undefined` for local-only
+ * state.
+ *
+ * • `''` — flat URL params (`?q=…&sort=…&country=…`). The dedicated
+ * browse pages (`/campaigns/all`, `/groups`, `/pledges`) want
+ * this so search results are shareable / linkable and survive
+ * refresh.
+ *
+ * • `undefined` — purely local state, no URL writes. The home
+ * page (`/`) hosts all three sections at once. Pushing each
+ * section's filters into the URL there would either collide
+ * (three sections want `?q=`) or pollute the path with six to
+ * nine prefixed params on every keystroke. Keeping state local
+ * means refreshing `/` lands on the curated idle view, which
+ * matches what we want anyway.
+ *
+ * • Any other string — namespaced URL params
+ * (`?fooQ=&fooSort=&fooCountry=`). Reserved for future surfaces
+ * that need multiple coexisting filter sets in the URL.
+ */
+ urlPrefix?: string;
+ /**
+ * Whether the section exposes a country picker. When `false`, the
+ * country slot stays `undefined` and the `country` URL param is
+ * never read or written even if a stale value sits in the URL.
+ * Defaults to `true`.
+ */
+ enableCountry?: boolean;
+}
+
+/**
+ * Filter state machine shared by every discovery section.
+ *
+ * Owns three pieces of state — search input (debounced), sort mode,
+ * country code — and (optionally) mirrors them to URL params so deep
+ * links and browser back/forward work. Defaults are stripped on write
+ * so the canonical URL stays clean (`/campaigns/all`, not
+ * `/campaigns/all?q=&sort=`).
+ *
+ * Debouncing lives inside this hook (300ms) so consumers don't have
+ * to thread the debounced value back in — that would create a
+ * circular dependency with the URL-sync effect. Consumers should
+ * pass `debouncedSearch` straight to their relay query.
+ *
+ * URL writes use `replace: true` so typing doesn't pile entries onto
+ * the history stack.
+ */
+export function useDiscoveryFilters({
+ urlPrefix,
+ enableCountry = true,
+}: UseDiscoveryFiltersOptions): DiscoveryFilters {
+ const useUrl = urlPrefix !== undefined;
+ // Always call the hook — React's rules — but only read/write through
+ // it when `useUrl` is true.
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const qKey = useUrl ? (urlPrefix === '' ? 'q' : `${urlPrefix}Q`) : '';
+ const sortKey = useUrl ? (urlPrefix === '' ? 'sort' : `${urlPrefix}Sort`) : '';
+ const countryKey = useUrl
+ ? urlPrefix === ''
+ ? 'country'
+ : `${urlPrefix}Country`
+ : '';
+
+ // Seed state from the URL on first render so deep links / refreshes
+ // restore the user's last view, then run the toolbar from local
+ // state and push debounced changes back to the URL.
+ const [searchInput, setSearchInputState] = useState(
+ useUrl ? (searchParams.get(qKey) ?? '') : '',
+ );
+ const [sort, setSortState] = useState(
+ useUrl ? parseSort(searchParams.get(sortKey)) : 'default',
+ );
+ const [country, setCountryState] = useState(
+ useUrl && enableCountry
+ ? (searchParams.get(countryKey) ?? undefined)
+ : undefined,
+ );
+
+ const debouncedSearch = useDebounce(searchInput, 300);
+
+ // URL → state. Handles browser back/forward and direct deep-link
+ // navigation while a section is mounted (e.g. clicking an internal
+ // link that updates `?sort=top`). We compare before assigning to
+ // avoid React render loops.
+ useEffect(() => {
+ if (!useUrl) return;
+ const urlQuery = searchParams.get(qKey) ?? '';
+ if (urlQuery !== searchInput && urlQuery !== debouncedSearch) {
+ setSearchInputState(urlQuery);
+ }
+ const urlSort = parseSort(searchParams.get(sortKey));
+ if (urlSort !== sort) setSortState(urlSort);
+ if (enableCountry) {
+ const urlCountry = searchParams.get(countryKey) ?? undefined;
+ if (urlCountry !== country) setCountryState(urlCountry);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchParams]);
+
+ // Debounced search → URL. Strip empty values so the canonical URL
+ // stays clean.
+ useEffect(() => {
+ if (!useUrl) return;
+ const next = new URLSearchParams(searchParams);
+ const trimmed = debouncedSearch.trim();
+ if (trimmed) next.set(qKey, trimmed);
+ else next.delete(qKey);
+ if (next.toString() !== searchParams.toString()) {
+ setSearchParams(next, { replace: true });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [debouncedSearch, useUrl]);
+
+ const setSort = useCallback(
+ (next: Nip50Sort) => {
+ setSortState(next);
+ if (!useUrl) return;
+ const params = new URLSearchParams(searchParams);
+ if (next === 'default') params.delete(sortKey);
+ else params.set(sortKey, next);
+ setSearchParams(params, { replace: true });
+ },
+ [useUrl, searchParams, setSearchParams, sortKey],
+ );
+
+ const setCountry = useCallback(
+ (next: string | undefined) => {
+ if (!enableCountry) return;
+ setCountryState(next);
+ if (!useUrl) return;
+ const params = new URLSearchParams(searchParams);
+ if (next) params.set(countryKey, next);
+ else params.delete(countryKey);
+ setSearchParams(params, { replace: true });
+ },
+ [enableCountry, useUrl, searchParams, setSearchParams, countryKey],
+ );
+
+ const setSearchInput = useCallback((next: string) => {
+ setSearchInputState(next);
+ // URL writes happen on `debouncedSearch` flipping, not per keystroke.
+ }, []);
+
+ return {
+ searchInput,
+ setSearchInput,
+ debouncedSearch,
+ sort,
+ setSort,
+ country,
+ setCountry,
+ };
+}
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 1f003e25..9eeeebe2 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -692,8 +692,6 @@
"community": "حملات المجتمع",
"communityDesc": "ساعد في تمويل التغييرات التي تستحق العناء.",
"browseAll": "تصفّح كل الحملات ←",
- "browseAllGroups": "تصفّح كل المجموعات ←",
- "browseAllPledges": "تصفّح كل التعهدات ←",
"pending": "بانتظار الموافقة",
"pendingDesc": "حملات موجودة على الشبكة لم يوافق عليها ولم يُخفها أي مشرف من فريق Soapbox بعد.",
"pendingEmpty": "لا يوجد شيء بانتظار المراجعة.",
diff --git a/src/locales/en.json b/src/locales/en.json
index c8ba81d4..d2932e16 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1128,8 +1128,6 @@
"community": "Community Campaigns",
"communityDesc": "Help fund the changes worth making.",
"browseAll": "Browse all campaigns →",
- "browseAllGroups": "Browse all groups →",
- "browseAllPledges": "Browse all pledges →",
"searchPlaceholder": "Search campaigns\u2026",
"searchAriaLabel": "Search campaigns",
"noMatch": "No campaigns match \u201c{{query}}\u201d",
diff --git a/src/locales/es.json b/src/locales/es.json
index cbf16b71..1966cf8a 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -704,8 +704,6 @@
"community": "Campañas de la comunidad",
"communityDesc": "Ayuda a financiar los cambios que valen la pena.",
"browseAll": "Ver todas las campañas →",
- "browseAllGroups": "Ver todos los grupos →",
- "browseAllPledges": "Ver todas las promesas →",
"pending": "Pendientes de aprobación",
"pendingDesc": "Campañas presentes en la red que ningún moderador del equipo Soapbox ha aprobado u ocultado todavía.",
"pendingEmpty": "Nada pendiente de revisión.",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 8474f951..9d12c314 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -704,8 +704,6 @@
"community": "کمپینهای جامعه",
"communityDesc": "به تأمین مالی تغییراتی که ارزش انجام دادن دارند کمک کنید.",
"browseAll": "← مرور همه کمپینها",
- "browseAllGroups": "← مرور همه گروهها",
- "browseAllPledges": "← مرور همه تعهدها",
"pending": "در انتظار تأیید",
"pendingDesc": "کمپینهایی که در شبکه هستند و هیچ ناظر تیم Soapbox هنوز آنها را تأیید یا پنهان نکرده است.",
"pendingEmpty": "چیزی برای بررسی نیست.",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index ad915014..50b5c1fd 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -1126,8 +1126,6 @@
"community": "Campagnes communautaires",
"communityDesc": "Aidez à financer les changements qui valent la peine d'être menés.",
"browseAll": "Parcourir toutes les campagnes →",
- "browseAllGroups": "Parcourir tous les groupes →",
- "browseAllPledges": "Parcourir toutes les promesses →",
"pending": "En attente d'approbation",
"pendingDesc": "Campagnes sur le réseau qu'aucun modérateur de Team Soapbox n'a encore approuvées ou masquées.",
"pendingEmpty": "Rien en attente d'examen.",
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 9a75d7e9..1877ec6e 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -1136,8 +1136,6 @@
"community": "कम्युनिटी कैंपेन",
"communityDesc": "उन बदलावों को फंड करने में मदद करें जिनके होने लायक़ हैं।",
"browseAll": "सभी कैंपेन देखें →",
- "browseAllGroups": "सभी ग्रुप देखें →",
- "browseAllPledges": "सभी प्लेज देखें →",
"pending": "मंज़ूरी का इंतज़ार",
"pendingDesc": "नेटवर्क पर ऐसे कैंपेन जिन्हें Team Soapbox के किसी मॉडरेटर ने अभी मंज़ूरी या छुपाया नहीं है।",
"pendingEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।",
diff --git a/src/locales/id.json b/src/locales/id.json
index 455b029f..2079bb2c 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -1136,8 +1136,6 @@
"community": "Kampanye Komunitas",
"communityDesc": "Bantu danai perubahan yang patut dilakukan.",
"browseAll": "Telusuri semua kampanye →",
- "browseAllGroups": "Telusuri semua grup →",
- "browseAllPledges": "Telusuri semua ikrar →",
"pending": "Menunggu persetujuan",
"pendingDesc": "Kampanye di jaringan yang belum disetujui atau disembunyikan oleh moderator Team Soapbox.",
"pendingEmpty": "Tidak ada yang menunggu peninjauan.",
diff --git a/src/locales/km.json b/src/locales/km.json
index bae9e204..8ea9366d 100644
--- a/src/locales/km.json
+++ b/src/locales/km.json
@@ -704,8 +704,6 @@
"community": "យុទ្ធនាការសហគមន៍",
"communityDesc": "ជួយផ្ដល់មូលនិធិដល់ការផ្លាស់ប្ដូរដែលសក្តិសម។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
- "browseAllGroups": "មើលក្រុមទាំងអស់ →",
- "browseAllPledges": "មើលការសន្យាទាំងអស់ →",
"pending": "កំពុងរង់ចាំការអនុម័ត",
"pendingDesc": "យុទ្ធនាការនៅលើបណ្ដាញដែលគ្មានអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត ឬលាក់នៅឡើយ។",
"pendingEmpty": "គ្មានអ្វីរង់ចាំការពិនិត្យ។",
diff --git a/src/locales/ps.json b/src/locales/ps.json
index cd60501a..3581a33f 100644
--- a/src/locales/ps.json
+++ b/src/locales/ps.json
@@ -704,8 +704,6 @@
"community": "د ټولنې کمپاینونه",
"communityDesc": "د هغو بدلونونو په تمویل کې مرسته وکړئ چې وړ دي.",
"browseAll": "← ټول کمپاینونه وګورئ",
- "browseAllGroups": "← ټولې ډلې وګورئ",
- "browseAllPledges": "← ټولې ژمنې وګورئ",
"pending": "د منلو په تمه",
"pendingDesc": "هغه کمپاینونه چې په شبکه کې شته، خو د Soapbox ټیم هیڅ مدیر یې لا تر اوسه نه دی منلی او نه پټ کړی.",
"pendingEmpty": "د بیاکتنې لپاره څه نشته.",
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 3ab30bbb..3d230be5 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -1136,8 +1136,6 @@
"community": "Campanhas da comunidade",
"communityDesc": "Ajude a financiar as mudanças que valem a pena.",
"browseAll": "Navegar por todas as campanhas →",
- "browseAllGroups": "Navegar por todos os grupos →",
- "browseAllPledges": "Navegar por todas as promessas →",
"pending": "Aguardando aprovação",
"pendingDesc": "Campanhas na rede que nenhum moderador da Team Soapbox aprovou ou ocultou ainda.",
"pendingEmpty": "Nada aguardando revisão.",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 395ea4d0..03d3d1b4 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -1136,8 +1136,6 @@
"community": "Кампании сообщества",
"communityDesc": "Помогите финансировать изменения, которые стоит сделать.",
"browseAll": "Просмотреть все кампании →",
- "browseAllGroups": "Просмотреть все группы →",
- "browseAllPledges": "Просмотреть все обещания →",
"pending": "Ожидают одобрения",
"pendingDesc": "Кампании в сети, которые ещё не были одобрены или скрыты модератором Team Soapbox.",
"pendingEmpty": "Ничего не ждёт проверки.",
diff --git a/src/locales/sn.json b/src/locales/sn.json
index 08afb06c..bcfd48e5 100644
--- a/src/locales/sn.json
+++ b/src/locales/sn.json
@@ -704,8 +704,6 @@
"community": "Mishandirapamwe yeNharaunda",
"communityDesc": "Batsira kupa mari kushanduko dzakakodzera.",
"browseAll": "Tarisa mishandirapamwe yose →",
- "browseAllGroups": "Tarisa mapoka ose →",
- "browseAllPledges": "Tarisa zvitsidziro zvose →",
"pending": "Yakamirira kutenderwa",
"pendingDesc": "Mishandirapamwe iri panetwork isati yatenderwa kana kuvanzwa naani zvake muTeam Soapbox.",
"pendingEmpty": "Hapana chinomirira kutariswa.",
diff --git a/src/locales/sw.json b/src/locales/sw.json
index c87b6e37..490cfa64 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -1135,8 +1135,6 @@
"community": "Kampeni za Jumuiya",
"communityDesc": "Saidia kufadhili mabadiliko yanayostahili kufanywa.",
"browseAll": "Vinjari kampeni zote →",
- "browseAllGroups": "Vinjari vikundi vyote →",
- "browseAllPledges": "Vinjari ahadi zote →",
"pending": "Inasubiri idhini",
"pendingDesc": "Kampeni kwenye mtandao ambazo hakuna msimamizi wa Team Soapbox aliyezithibitisha au kuzificha bado.",
"pendingEmpty": "Hakuna kinachosubiri ukaguzi.",
diff --git a/src/locales/tr.json b/src/locales/tr.json
index f8609cd2..ad4d573d 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -1135,8 +1135,6 @@
"community": "Topluluk Kampanyaları",
"communityDesc": "Yapmaya değer değişiklikleri finanse etmeye yardım edin.",
"browseAll": "Tüm kampanyalara göz at →",
- "browseAllGroups": "Tüm gruplara göz at →",
- "browseAllPledges": "Tüm taahhütlere göz at →",
"pending": "Onay bekliyor",
"pendingDesc": "Ağdaki, henüz hiçbir Team Soapbox moderatörünün onaylamadığı veya gizlemediği kampanyalar.",
"pendingEmpty": "İncelemeyi bekleyen bir şey yok.",
diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json
index 58736221..8d9e9d9c 100644
--- a/src/locales/zh-Hant.json
+++ b/src/locales/zh-Hant.json
@@ -704,8 +704,6 @@
"community": "社群活動",
"communityDesc": "為值得做的改變提供資金。",
"browseAll": "瀏覽所有活動 →",
- "browseAllGroups": "瀏覽所有群組 →",
- "browseAllPledges": "瀏覽所有懸賞 →",
"pending": "等待審批",
"pendingDesc": "網路上尚未被任何 Soapbox 團隊版主批准或隱藏的活動。",
"pendingEmpty": "沒有等待審查的內容。",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 0783ca58..db6b35c7 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -704,8 +704,6 @@
"community": "社区活动",
"communityDesc": "为值得做的改变提供资金。",
"browseAll": "浏览所有活动 →",
- "browseAllGroups": "浏览所有群组 →",
- "browseAllPledges": "浏览所有悬赏 →",
"pending": "等待审批",
"pendingDesc": "网络上尚未被任何 Soapbox 团队版主批准或隐藏的活动。",
"pendingEmpty": "没有等待审查的内容。",
diff --git a/src/pages/ActionsPage.tsx b/src/pages/ActionsPage.tsx
index 18d1c60c..ff32b096 100644
--- a/src/pages/ActionsPage.tsx
+++ b/src/pages/ActionsPage.tsx
@@ -1,47 +1,37 @@
import { useEffect, useState, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
-import { useNavigate } from 'react-router-dom';
-import { useQueryClient } from '@tanstack/react-query';
+import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { nip19 } from 'nostr-tools';
+import {
+ ChevronDown,
+ ChevronUp,
+ EyeOff,
+ Megaphone,
+ PlusCircle,
+} from 'lucide-react';
-import { parseAction, useActions, type Action } from '@/hooks/useActions';
+import { ActionShareMenu } from '@/components/ActionShareMenu';
+import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection';
+import { useActions, type Action } from '@/hooks/useActions';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBtcPrice } from '@/hooks/useBtcPrice';
-import { useDebounce } from '@/hooks/useDebounce';
-import { useNip50Search, type Nip50Sort } from '@/hooks/useNip50Search';
-import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
-import { useShareOrigin } from '@/hooks/useShareOrigin';
-import { useToast } from '@/hooks/useToast';
import { getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
-import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
-import { ModerationMenuItems, ModerationOverlay, ModeratorCollapsibleSection } from '@/components/moderation';
+import {
+ ModerationOverlay,
+ ModeratorCollapsibleSection,
+} from '@/components/moderation';
import { PledgeCard } from '@/components/PledgeCard';
-
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
-import {
- DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-
-import {
- HandHeart, PlusCircle, ChevronDown, ChevronUp, Loader2,
- Link as LinkIcon, Check, MoreHorizontal, Trash2,
- Megaphone, EyeOff,
-} from 'lucide-react';
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Skeletons / Cards
-// ─────────────────────────────────────────────────────────────────────────────
function getPledgeCoord(action: Action) {
return `36639:${action.pubkey}:${action.id}`;
@@ -62,150 +52,19 @@ function ActionSkeleton() {
);
}
-function ActionShareMenu({ action, displayTitle }: { action: Action; displayTitle: string }) {
- const { t } = useTranslation();
- const { user } = useCurrentUser();
- const { data: moderators } = useCampaignModerators();
- const { mutateAsync: createEvent } = useNostrPublish();
- const { toast } = useToast();
- const shareOrigin = useShareOrigin();
- const queryClient = useQueryClient();
- const [copied, setCopied] = useState(false);
- const [isDeleting, setIsDeleting] = useState(false);
-
- const isOwner = user?.pubkey === action.pubkey;
- // Moderator gate is identical to the one in `ModerationMenuItems`,
- // duplicated here so we can decide whether to render the trailing
- // separator that introduces the moderator section. `ModerationMenuItems`
- // returns `null` for non-mods, so without this check we'd render an
- // orphaned separator at the bottom of the dropdown.
- const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
-
- const naddr = nip19.naddrEncode({
- kind: 36639,
- pubkey: action.pubkey,
- identifier: action.id,
- });
-
- const actionUrl = `${shareOrigin}/${naddr}`;
-
- const handleCopyLink = async () => {
- try {
- await navigator.clipboard.writeText(actionUrl);
- setCopied(true);
- toast({ title: t('pledges.card.linkCopied') });
- setTimeout(() => setCopied(false), 2000);
- } catch (error) {
- console.error('Failed to copy link:', error);
- toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' });
- }
- };
-
- const handleDelete = async (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation();
- if (!user || !isOwner) return;
-
- const confirmed = window.confirm(t('pledges.card.confirmDelete'));
- if (!confirmed) return;
-
- setIsDeleting(true);
- try {
- // NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't
- // honour a-tag-only deletions for addressable events.
- await createEvent({
- kind: 5,
- content: t('pledges.card.deletedContent'),
- tags: [
- ['e', action.event.id],
- ['a', getPledgeCoord(action)],
- ],
- });
- // Extract any organization `A` tag the pledge was associated with so
- // the org's activity shelf and community feeds refresh too.
- const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1];
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: ['agora-actions'] }),
- queryClient.invalidateQueries({ queryKey: ['agora-action'] }),
- ...(orgATag
- ? [
- queryClient.invalidateQueries({ queryKey: ['organization-activity', orgATag] }),
- queryClient.invalidateQueries({ queryKey: ['community-actions', orgATag] }),
- queryClient.invalidateQueries({
- predicate: (q) => {
- const [root, aTagsKey] = q.queryKey;
- return root === 'community-activity-feed'
- && typeof aTagsKey === 'string'
- && aTagsKey.split(',').includes(orgATag);
- },
- }),
- ]
- : []),
- ]);
- toast({ title: t('pledges.card.deleted') });
- } catch (error) {
- console.error('Failed to delete pledge:', error);
- toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' });
- } finally {
- setIsDeleting(false);
- }
- };
-
- return (
-
- e.preventDefault()}>
-
-
- e.stopPropagation()}>
- {isOwner && (
- <>
-
- {isDeleting ? (
-
- ) : (
-
- )}
- {t('pledges.card.deletePledge')}
-
-
- >
- )}
-
- {copied ? (
-
- ) : (
-
- )}
- {t('pledges.card.copyLink')}
-
- {/* Moderator actions appear under a separator when the viewer
- is a Team Soapbox moderator. `ModerationMenuItems` returns
- null for non-mods, so we gate the trailing separator on the
- same `isMod` check to avoid an orphan separator at the
- bottom of non-mod dropdowns. */}
- {isMod && }
-
-
-
- );
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Page
-// ─────────────────────────────────────────────────────────────────────────────
-
+/**
+ * Dedicated `/pledges` page.
+ *
+ * Thin shell around the shared {@link PledgesDiscoverySection}:
+ * hero, optional "My pledges" shelf, the unified search-and-discover
+ * section, and a moderator-only Hidden collapsible.
+ *
+ * URL state (`?q=&sort=&country=`) lives inside the section's
+ * `useDiscoveryFilters` hook so search results stay shareable. The
+ * page reads `?country=` independently to thread it into the
+ * create-pledge href so "Create pledge" preserves the active country
+ * filter into the form.
+ */
export default function ActionsPage() {
const { t } = useTranslation();
const { config } = useAppContext();
@@ -213,142 +72,44 @@ export default function ActionsPage() {
const { data: btcPrice } = useBtcPrice();
const navigate = useNavigate();
- const [selectedCountry, setSelectedCountry] = useState(undefined);
+ // Mirror the section's `?country=` so the create-pledge href can
+ // carry it forward into the form pre-fill (matches the old modal's
+ // `countryCode` prop behaviour). The section's filters hook is the
+ // source of truth; we only read here.
+ const [searchParams] = useSearchParams();
+ const selectedCountry = searchParams.get('country') ?? undefined;
- // On-page NIP-50 search + sort + show-hidden toolbar state.
- //
- // Default sort, empty query → curated active / upcoming / past
- // sections below.
- // Default sort, with query → relay search for kind 36639, results
- // post-filtered against title/content client-side.
- // Top / New → always active. Top sends `sort:top`;
- // New sends a raw chronological feed of the kind.
- //
- // The country filter is threaded through to the search as a NIP-73
- // `#i` tag filter (`iso3166:XX` + legacy `geo:XX`). Picking a country
- // with an empty query still activates the search view — narrowing a
- // kind by external identifier produces a useful filtered grid even
- // without a typed term.
- const [searchInput, setSearchInput] = useState('');
- const [sortMode, setSortMode] = useState('default');
- const [showHidden, setShowHidden] = useState(false);
- const debouncedSearch = useDebounce(searchInput, 300);
- const trimmedSearch = debouncedSearch.trim();
- const iTags = useMemo(() => {
- if (!selectedCountry) return undefined;
- const code = selectedCountry.toUpperCase();
- return [`iso3166:${code}`, `geo:${code}`];
- }, [selectedCountry]);
- const {
- data: searchHitsRaw,
- isFetching: isSearchFetching,
- isActive: isSearching,
- } = useNip50Search({
- kind: 36639,
- query: debouncedSearch,
- sort: sortMode,
- parse: parseAction,
- iTags,
- // Pledge titles live in a `title` tag, not `content`. Most NIP-50
- // implementations only match content; widen the net client-side.
- getKeywordHaystack: (event) => {
- const title = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
- return [title, event.content];
- },
- });
-
- // Moderator gate. Reuses the campaign moderator pack (Team Soapbox) —
- // the pledge moderation namespace rides the same signer set as the
- // campaign and group surfaces.
+ // Moderator gate. Reuses the campaign moderator pack (Team Soapbox)
+ // — the pledge moderation namespace rides the same signer set as
+ // the campaign and group surfaces.
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
- const canShowHidden = isMod && showHidden;
-
- const { data: rawActions, isLoading: actionsLoading } = useActions({
- countryCode: selectedCountry,
- limit: 300,
- });
+ const [showHidden, setShowHidden] = useState(false);
const { data: myPledges } = useActions({
authors: user ? [user.pubkey] : undefined,
limit: 100,
enabled: !!user,
});
+ // Moderator-only feed of every pledge on the network — drives the
+ // Hidden collapsible and the toolbar's hidden-count badge.
const { data: allPledgesForMods, isLoading: allPledgesLoading } = useActions({
limit: 300,
enabled: isMod,
});
+ const { data: pledgeModeration, isReady: pledgeModerationReady } =
+ usePledgeModeration();
- const { data: pledgeModeration, isReady: pledgeModerationReady } = usePledgeModeration();
+ const hiddenPledges = useMemo(() => {
+ if (!isMod || !pledgeModerationReady) return [];
+ return (allPledgesForMods ?? []).filter((pledge) =>
+ pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge)),
+ );
+ }, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]);
- const featuredPledgeCoords = useMemo(() => {
- if (!pledgeModerationReady) return [] as string[];
- return Array.from(pledgeModeration.featuredCoords)
- .filter((coord) => !pledgeModeration.hiddenCoords.has(coord))
- .sort((a, b) => (pledgeModeration.featuredOrder.get(b) ?? 0) - (pledgeModeration.featuredOrder.get(a) ?? 0));
- }, [pledgeModeration, pledgeModerationReady]);
-
- const { data: featuredPledges } = useActions({
- coordinates: featuredPledgeCoords,
- limit: featuredPledgeCoords.length || 1,
- enabled: pledgeModerationReady,
- });
-
- const orderedFeaturedPledges = useMemo(() => {
- if (!featuredPledges) return [] as Action[];
- const order = pledgeModeration.featuredOrder;
- return [...featuredPledges].sort((a, b) => {
- const aCoord = getPledgeCoord(a);
- const bCoord = getPledgeCoord(b);
- return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0);
- });
- }, [featuredPledges, pledgeModeration]);
-
- const featuredPledgeCoordSet = useMemo(() => new Set(featuredPledgeCoords), [featuredPledgeCoords]);
-
- const { searchHits, searchHiddenCount } = useMemo(() => {
- if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
- const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set();
- let hidden = 0;
- const visible: Action[] = [];
- for (const a of searchHitsRaw) {
- const coord = getPledgeCoord(a);
- if (hiddenCoords.has(coord)) {
- hidden += 1;
- if (canShowHidden) visible.push(a);
- } else {
- visible.push(a);
- }
- }
- return { searchHits: visible, searchHiddenCount: hidden };
- }, [searchHitsRaw, pledgeModeration, canShowHidden]);
-
- // `actions` is the chronological pledge list filtered by country, with
- // moderator-hidden items dropped (unless `showHidden` is on). Featured
- // pledges are NOT excluded here — the idle render path pulls them
- // separately, and the active render path shows the full list.
- const { actions, listHiddenCount } = useMemo(() => {
- if (!rawActions) return { actions: undefined, listHiddenCount: 0 };
- const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set();
- let hidden = 0;
- const visible: Action[] = [];
-
- for (const action of rawActions) {
- const coord = getPledgeCoord(action);
- if (hiddenCoords.has(coord)) {
- hidden += 1;
- if (canShowHidden) visible.push(action);
- } else {
- visible.push(action);
- }
- }
-
- return { actions: visible, listHiddenCount: hidden };
- }, [rawActions, pledgeModeration, canShowHidden]);
-
- // Route entry points for "Create pledge" all pass the currently-selected
- // country via ?country= so the dedicated page can pre-fill it, matching
- // the old modal's `countryCode` prop.
+ // Route entry points for "Create pledge" all pass the currently
+ // selected country via ?country= so the dedicated page can
+ // pre-fill it, matching the old modal's `countryCode` prop.
const createActionHref = selectedCountry
? `/pledges/new?country=${encodeURIComponent(selectedCountry)}`
: '/pledges/new';
@@ -358,57 +119,24 @@ export default function ActionsPage() {
: t('pledges.list.global');
useSeoMeta({
- title: `${selectedCountry
- ? t('pledges.list.seoTitleWithCountry', { country: selectedCountryName })
- : t('pledges.list.seoTitle')} | ${config.appName}`,
+ title: `${
+ selectedCountry
+ ? t('pledges.list.seoTitleWithCountry', { country: selectedCountryName })
+ : t('pledges.list.seoTitle')
+ } | ${config.appName}`,
description: t('pledges.list.seoDescription'),
});
- const isLoading = actionsLoading || !pledgeModerationReady;
- const isSearchLoading = isSearchFetching || !pledgeModerationReady;
-
const DEFAULT_VISIBLE = 4;
const [showAllMine, setShowAllMine] = useState(false);
-
- const visibleMine = showAllMine ? (myPledges ?? []) : (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
-
- // Idle list: featured first; if none are featured, fall back to the
- // chronological all-pledges grid so the page is never blank.
- const idlePledges = useMemo(() => {
- if (orderedFeaturedPledges.length > 0) return orderedFeaturedPledges;
- return (actions ?? []).filter(
- (action) => !featuredPledgeCoordSet.has(getPledgeCoord(action)),
- );
- }, [orderedFeaturedPledges, actions, featuredPledgeCoordSet]);
-
- const hiddenPledges = useMemo(() => {
- if (!isMod || !pledgeModerationReady) return [];
- return (allPledgesForMods ?? []).filter((pledge) => pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge)));
- }, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]);
-
- const headerControls = (
-
- );
+ const visibleMine = showAllMine
+ ? (myPledges ?? [])
+ : (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
return (
navigate(createActionHref)}
/>
@@ -417,8 +145,12 @@ export default function ActionsPage() {
{user && myPledges && myPledges.length > 0 && (
-
{t('pledges.list.myPledges')}
-
{t('pledges.list.myPledgesTagline')}
+
+ {t('pledges.list.myPledges')}
+
+
+ {t('pledges.list.myPledgesTagline')}
+
)}
- {/* Unified Pledges section.
- - Idle (no search / no sort / no country): renders the
- moderator-featured grid, or a chronological fallback when
- no pledges are featured yet.
- - Active: renders the full search/sort/country result set. */}
-
-
-
- )}
-
+
{isMod && (
- {Array.from({ length: 4 }).map((_, i) => )}
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
}
>
@@ -589,7 +210,10 @@ export default function ActionsPage() {
showMenu={false}
className="flex items-center"
/>
-
+
>
}
/>
@@ -630,10 +254,10 @@ interface ActionsHeroProps {
/**
* Photo-led hero for the Pledges index. Same structural recipe as the
* Organize hero (rotating banner + atmospheric tint + scrims + overlay
- * copy + glassy CTA), but tuned for the pledge page's "dawn / golden hour" vibe:
- * uses {@link HOPE_PALETTE} instead of the cool palette so the warm
- * hues land on top of the protest photography rather than competing
- * with it.
+ * copy + glassy CTA), but tuned for the pledge page's "dawn / golden
+ * hour" vibe: uses {@link HOPE_PALETTE} instead of the cool palette
+ * so the warm hues land on top of the protest photography rather
+ * than competing with it.
*/
function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProps) {
const { t } = useTranslation();
@@ -699,7 +323,10 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
aria-live="polite"
>
-
+
{actionCount.toLocaleString()}
@@ -725,7 +352,11 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
'motion-safe:transition-colors motion-safe:duration-200',
'disabled:opacity-60 disabled:cursor-not-allowed',
)}
- aria-label={canCreate ? t('pledges.list.createPledge') : t('pledges.list.loginToCreate')}
+ aria-label={
+ canCreate
+ ? t('pledges.list.createPledge')
+ : t('pledges.list.loginToCreate')
+ }
>
{t('pledges.list.createPledge')}
@@ -737,9 +368,19 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
}
function ActionSection({
- items, total, visible, showAll, onToggle, btcPrice,
+ items,
+ total,
+ visible,
+ showAll,
+ onToggle,
+ btcPrice,
}: {
- items: Action[]; total: number; visible: number; showAll: boolean; onToggle: () => void; btcPrice: number | undefined;
+ items: Action[];
+ total: number;
+ visible: number;
+ showAll: boolean;
+ onToggle: () => void;
+ btcPrice: number | undefined;
}) {
const { t } = useTranslation();
return (
diff --git a/src/pages/AllCampaignsPage.tsx b/src/pages/AllCampaignsPage.tsx
index acb903a5..019b8b63 100644
--- a/src/pages/AllCampaignsPage.tsx
+++ b/src/pages/AllCampaignsPage.tsx
@@ -5,9 +5,8 @@ import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronUp, EyeOff, HandHeart, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
-import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
+import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { ModeratorCollapsibleSection } from '@/components/moderation';
@@ -16,7 +15,6 @@ import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useAppContext } from '@/hooks/useAppContext';
-import { useDebounce } from '@/hooks/useDebounce';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
@@ -24,11 +22,9 @@ import type { Nip50Sort } from '@/hooks/useNip50Search';
import type { ParsedCampaign } from '@/lib/campaign';
/**
- * Type-guard for the `?sort=` URL param.
- *
- * - `top` and `new` map to the toolbar's active sort modes.
- * - Anything else (missing, empty, legacy values) collapses to
- * `'default'`, the curated featured-first idle state.
+ * Type-guard mirroring the one in `useDiscoveryFilters`. Pulled out
+ * to keep the page's hidden-section derivation self-contained, since
+ * it needs to read the same URL params the section reads.
*/
function parseSort(value: string | null): Nip50Sort {
if (value === 'top') return 'top';
@@ -36,183 +32,84 @@ function parseSort(value: string | null): Nip50Sort {
return 'default';
}
-/**
- * Map the toolbar's sort vocabulary (`default` / `top` / `new`) to the
- * `useAllCampaigns` hook's vocabulary (`top` / `none`). `'new'` and
- * `'default'` both map to `'none'` (chronological) — the page handles
- * the "show featured only" framing on top of that.
- */
+/** Toolbar sort vocabulary → useAllCampaigns vocabulary. */
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none');
/**
- * Lists every campaign found on relays. The page has two display modes:
+ * Lists every campaign found on relays.
*
- * 1. **Idle** (no search, no sort, no country picked) — shows
- * moderator-featured campaigns only. If there are no featured
- * campaigns, falls back to the latest chronological grid so the page
- * is never blank.
- * 2. **Active** (user typed a query, picked Top/New, or chose a
- * country) — shows the full set, ranked by sats raised (Top) or
- * chronological (New / search default).
+ * The page itself is a thin shell: hero, optional "Your campaigns"
+ * shelf, the shared {@link CampaignsDiscoverySection} (which owns
+ * search / sort / country + idle / active grids), and a
+ * moderator-only Hidden collapsible.
*
- * Search filters across title, summary, story, location, and category
- * tags client-side.
- *
- * Hidden campaigns are excluded by default — flip the "Show hidden"
- * toggle (inside the toolbar's filter popover) to include them.
- *
- * URL state: `?sort=top|new&q=&country=`. Default values
- * are stripped so the canonical URL stays clean. Useful for sharing
- * search results.
+ * URL state (`?q=&sort=&country=`) lives inside the section's
+ * `useDiscoveryFilters` hook so search results stay shareable. The
+ * page reads the same params independently to compute the Hidden
+ * collapsible's contents — TanStack Query dedupes the underlying
+ * `useAllCampaigns` call, so there's no extra network round-trip.
*/
export function AllCampaignsPage() {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
- // URL state — sort, query, and country live in the URL so results are
- // shareable.
- const [searchParams, setSearchParams] = useSearchParams();
+ const [searchParams] = useSearchParams();
const sort = parseSort(searchParams.get('sort'));
const urlQuery = searchParams.get('q') ?? '';
const urlCountry = searchParams.get('country') ?? undefined;
- // Search input is local-state so typing is responsive; we debounce to
- // the URL + the query.
- const [searchInput, setSearchInput] = useState(urlQuery);
- const debouncedSearch = useDebounce(searchInput, 300);
- const [showHidden, setShowHidden] = useState(false);
-
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
+ const [showHidden, setShowHidden] = useState(false);
- // Sync the debounced search → URL. Empty / default values are stripped
- // so the canonical URL is `/campaigns/all` (not
- // `/campaigns/all?sort=&q=`).
- useEffect(() => {
- const next = new URLSearchParams(searchParams);
- const trimmed = debouncedSearch.trim();
- if (trimmed) next.set('q', trimmed);
- else next.delete('q');
- // Only replace history when the params actually change, to avoid
- // looping when the URL is already in sync.
- if (next.toString() !== searchParams.toString()) {
- setSearchParams(next, { replace: true });
- }
- }, [debouncedSearch, searchParams, setSearchParams]);
-
- // Sync URL → input (e.g. browser back/forward or a deep link).
- useEffect(() => {
- if (urlQuery !== debouncedSearch) {
- setSearchInput(urlQuery);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [urlQuery]);
-
- const setSortFromToolbar = (value: Nip50Sort) => {
- const next = new URLSearchParams(searchParams);
- if (value === 'top') next.set('sort', 'top');
- else if (value === 'new') next.set('sort', 'new');
- else next.delete('sort');
- setSearchParams(next, { replace: true });
- };
-
- // The country picker also rides the URL so country-scoped views are
- // shareable / linkable.
- const setCountry = (next: string | undefined) => {
- const params = new URLSearchParams(searchParams);
- if (next) params.set('country', next);
- else params.delete('country');
- setSearchParams(params, { replace: true });
- };
-
- const { data: campaigns, isLoading } = useAllCampaigns({
+ // Mirror the section's underlying query so the Hidden collapsible
+ // can list the exact set of hidden items matching the current
+ // search / sort / country. TanStack dedupes; this is a cache read
+ // on the same key the section uses.
+ const { data: campaigns } = useAllCampaigns({
sort: toQuerySort(sort),
- search: debouncedSearch.trim(),
+ search: urlQuery,
countryCode: urlCountry,
limit: 200,
});
- const { data: moderation, isReady: moderationReady } = useCampaignModeration();
+
+ const { data: moderation } = useCampaignModeration();
+
+ const { hiddenCount, hiddenCampaigns } = useMemo(() => {
+ const all = campaigns ?? [];
+ const hiddenCoords = moderation?.hiddenCoords ?? new Set();
+ let count = 0;
+ const list: ParsedCampaign[] = [];
+ for (const c of all) {
+ if (hiddenCoords.has(c.aTag)) {
+ count += 1;
+ list.push(c);
+ }
+ }
+ return { hiddenCount: count, hiddenCampaigns: list };
+ }, [campaigns, moderation]);
+
const { data: myCampaigns } = useCampaigns({
authors: user ? [user.pubkey] : undefined,
limit: 100,
enabled: !!user,
});
- const featuredCoords = useMemo(() => {
- if (!moderationReady) return [] as string[];
- return Array.from(moderation.featuredCoords)
- .filter((coord) => !moderation.hiddenCoords.has(coord))
- .sort((a, b) => (moderation.featuredOrder.get(b) ?? 0) - (moderation.featuredOrder.get(a) ?? 0));
- }, [moderation, moderationReady]);
-
- const { data: featuredCampaigns } = useCampaigns({
- coordinates: featuredCoords,
- limit: featuredCoords.length || 1,
- enabled: moderationReady,
- });
-
useSeoMeta({
title: `${t('campaigns.all.seoTitle')} | ${config.appName}`,
description: t('campaigns.all.description'),
});
- const activeQuery = debouncedSearch.trim();
-
- // The unified section is in "active" mode when the user has expressed
- // intent to browse the full set: typed a query, picked Top/New, or
- // chosen a country. Otherwise it's the curated featured-first view.
- const isActive = activeQuery !== '' || sort !== 'default' || !!urlCountry;
-
- // Visible campaigns in the **active** branch: every campaign matching
- // the search/sort/country, minus hidden (unless the moderator opted
- // in to seeing hidden). Featured items are intentionally NOT pulled
- // out of this list — when the user is actively browsing, they want a
- // ranked or chronological grid, not the curated shelf.
- const { visible, hiddenCount, hiddenCampaigns } = useMemo(() => {
- const all = campaigns ?? [];
- const hiddenCoords = moderation?.hiddenCoords ?? new Set();
- let hiddenCount = 0;
- const visible: ParsedCampaign[] = [];
- const hiddenCampaigns: ParsedCampaign[] = [];
-
- for (const c of all) {
- if (hiddenCoords.has(c.aTag)) {
- hiddenCount += 1;
- hiddenCampaigns.push(c);
- if (isMod && showHidden) visible.push(c);
- } else {
- visible.push(c);
- }
- }
-
- return { visible, hiddenCount, hiddenCampaigns };
- }, [campaigns, isMod, moderation, showHidden]);
-
- const orderedFeaturedCampaigns = useMemo(() => {
- if (!featuredCampaigns) return [] as ParsedCampaign[];
- return [...featuredCampaigns].sort(
- (a, b) => (moderation.featuredOrder.get(b.aTag) ?? 0) - (moderation.featuredOrder.get(a.aTag) ?? 0),
- );
- }, [featuredCampaigns, moderation]);
-
- // Idle-mode list: featured first; if none are featured, fall back to
- // the latest chronological grid so the page never lands on an empty
- // state when there's content to show.
- const idleCampaigns = useMemo(() => {
- if (orderedFeaturedCampaigns.length > 0) return orderedFeaturedCampaigns;
- return visible;
- }, [orderedFeaturedCampaigns, visible]);
-
const DEFAULT_VISIBLE = 4;
const [showAllMine, setShowAllMine] = useState(false);
- const visibleMine = showAllMine ? (myCampaigns ?? []) : (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
-
- const showSkeleton = isLoading || !moderationReady;
+ const visibleMine = showAllMine
+ ? (myCampaigns ?? [])
+ : (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
return (
-
+
{user && myCampaigns && myCampaigns.length > 0 && (
@@ -235,116 +132,38 @@ export function AllCampaignsPage() {
)}
- {/* Unified Campaigns section.
- - Idle (no search / no sort / no country): renders the
- moderator-curated featured grid, or a chronological
- fallback if nothing is featured yet.
- - Active: renders the full search/sort/country result set. */}
-
-
+
- {/* Grid — widens to 3 columns at lg and 4 at xl so desktop
- users can scan more campaigns at once, matching the Pledge
- index's card density. Mobile and small tablets stay
- single / double column so the cards keep their tappable
- size. */}
- {showSkeleton ? (
-
- )}
-
-
- {/* Moderator-only: every hidden campaign on the network. Mirrors
- the section on `/campaigns` so moderators see the same
- "Hidden" affordance whether they're browsing the curated
- home or the full index. */}
+ {/* Moderator-only: every hidden campaign on the network matching
+ the current section filters. The section drops hidden items
+ from its main grid unless the toolbar's Show-hidden switch
+ is on; this collapsible always exposes them so a moderator
+ can act on hidden coords without flipping the visibility
+ mode. */}
{isMod && (
}
title={t('campaigns.home.hidden')}
description={t('campaigns.home.hiddenDesc')}
count={hiddenCampaigns.length}
- isLoading={showSkeleton}
+ isLoading={!moderation}
emptyText={t('campaigns.home.hiddenEmpty')}
skeleton={
-
+
{campaignCount.toLocaleString()}
diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx
index c01f04ef..d3e3b43c 100644
--- a/src/pages/CampaignsPage.tsx
+++ b/src/pages/CampaignsPage.tsx
@@ -1,60 +1,33 @@
-import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Trans, useTranslation } from 'react-i18next';
import { ArrowRight, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
-import { Card } from '@/components/ui/card';
-import { Skeleton } from '@/components/ui/skeleton';
-import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
-import {
- CommunityMiniCard,
- CommunityMiniCardSkeleton,
-} from '@/components/discovery/CommunityMiniCard';
-import { CommunityGrid } from '@/components/discovery/CommunityGrid';
import { HeroLightningMap } from '@/components/HeroLightningMap';
-import { PledgeCard } from '@/components/PledgeCard';
-import { useActions, type Action } from '@/hooks/useActions';
+import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection';
+import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection';
+import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection';
import { useAppContext } from '@/hooks/useAppContext';
-import { useBtcPrice } from '@/hooks/useBtcPrice';
-import { useCampaigns } from '@/hooks/useCampaigns';
-import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
-import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
-import { usePledgeModeration } from '@/hooks/usePledgeModeration';
-import type { ParsedCampaign } from '@/lib/campaign';
-import type { ParsedCommunity } from '@/lib/communityUtils';
-
-/** Cap on how many featured campaigns we render in the home-page row. */
-const MAX_FEATURED_CAMPAIGNS = 4;
-/**
- * Cap on featured groups and featured pledges in their respective home-page
- * sections. The dedicated pages render unlimited featured items; the home
- * page is a launchpad, not the canonical list.
- */
-const MAX_FEATURED_PER_SECTION = 8;
-
-function getPledgeCoord(action: Action) {
- return `36639:${action.pubkey}:${action.id}`;
-}
/**
* Home page (`/`).
*
- * A curated launchpad: hero on top, then three featured sections — campaigns,
- * groups, pledges — each capped to a digestible row and linking out to its
- * dedicated browse page (`/campaigns/all`, `/groups`, `/pledges`). The home
- * page intentionally does *not* show community/pending/hidden grids,
- * unmoderated streams, or per-viewer "your X" shelves — those live on the
- * dedicated pages so the home stays scannable on every visit.
+ * Hero on top, then the three discovery sections back-to-back —
+ * Campaigns, Groups, Pledges — each with the same title, tagline,
+ * and search/sort/country toolbar as its dedicated page. Filter
+ * state is purely local here (`filterPersistence="local"`):
+ * persisting three sets of `?q=&sort=&country=` would either
+ * collide (three sections want `?q=`) or pollute the URL with
+ * prefixed variants on every keystroke. Refreshing `/` always
+ * lands on the curated idle view, which matches what we want
+ * anyway. Users who want shareable / persistent filters go to
+ * `/campaigns/all`, `/groups`, or `/pledges`.
*
- * Each section's "featured" set is derived from the same moderation labels
- * used on its dedicated page, so what surfaces here matches what surfaces
- * there (just truncated). Sections with no featured items collapse silently
- * rather than render an empty card; the page can degrade to "hero + one
- * section" without looking broken.
+ * The home page intentionally omits the moderator-only Hidden
+ * collapsibles and per-viewer "My X" shelves — those live on the
+ * dedicated pages so the home stays scannable on every visit.
*/
export function CampaignsPage() {
const { t } = useTranslation();
@@ -70,10 +43,13 @@ export function CampaignsPage() {
-
- );
- }
-
- if (campaigns.length === 0) {
- // Defensive — the parent guards on `featuredCoords.length > 0`, but if
- // a hidden-after-featured race leaves us with no campaigns to render,
- // collapse silently rather than show an empty card.
- return null;
- }
-
- // 1 featured campaign gets the hero `variant="featured"` treatment;
- // 2-4 use the regular compact card sized to the dynamic grid.
- const useFeaturedVariant = campaigns.length === 1;
-
- return (
-
- {campaigns.map((campaign) => (
-
- ))}
-
- );
-}
-
-// ═══════════════════════════════════════════════════════════════════════════════
-// Featured Groups
-// ═══════════════════════════════════════════════════════════════════════════════
-
-function FeaturedGroupsSection() {
- const { t } = useTranslation();
- const { data: orgModeration, isReady: orgModerationReady } =
- useOrganizationModeration();
- const { data: featuredOrgs, isLoading: featuredOrgsLoading } =
- useFeaturedOrganizations();
-
- const featuredGroups = useMemo(() => {
- if (!featuredOrgs) return [];
- const hiddenCoords = orgModeration?.hiddenCoords ?? new Set();
- return featuredOrgs
- .map((entry) => entry.community)
- .filter((c) => !hiddenCoords.has(c.aTag))
- .slice(0, MAX_FEATURED_PER_SECTION);
- }, [featuredOrgs, orgModeration]);
-
- // `useFeaturedOrganizations` is internally gated on moderation readiness,
- // so while moderation labels are still resolving the underlying query is
- // disabled and reports `isLoading: false` / `data: undefined`. Treat any
- // of "moderation not ready / featured query in flight / featured data
- // not yet defined" as loading so the skeleton stays on screen until we
- // know what's featured.
- const isLoading =
- !orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined;
-
- if (!isLoading && featuredGroups.length === 0) return null;
-
- return (
-
-
-
- {isLoading ? (
-
- {Array.from({ length: 8 }).map((_, i) => (
-
- ))}
-
- ) : (
-
- {featuredGroups.map((community) => (
-
- ))}
-
- )}
-
- );
-}
-
-// ═══════════════════════════════════════════════════════════════════════════════
-// Featured Pledges
-// ═══════════════════════════════════════════════════════════════════════════════
-
-function FeaturedPledgesSection() {
- const { t } = useTranslation();
- const { config } = useAppContext();
- const { data: btcPrice } = useBtcPrice();
- const { data: pledgeModeration, isReady: pledgeModerationReady } =
- usePledgeModeration();
-
- const featuredPledgeCoords = useMemo(() => {
- if (!pledgeModerationReady) return [] as string[];
- return Array.from(pledgeModeration.featuredCoords)
- .filter((coord) => !pledgeModeration.hiddenCoords.has(coord))
- .sort(
- (a, b) =>
- (pledgeModeration.featuredOrder.get(b) ?? 0) -
- (pledgeModeration.featuredOrder.get(a) ?? 0),
- )
- .slice(0, MAX_FEATURED_PER_SECTION);
- }, [pledgeModeration, pledgeModerationReady]);
-
- const { data: featuredPledges, isLoading: featuredPledgesLoading } = useActions({
- coordinates: featuredPledgeCoords,
- limit: featuredPledgeCoords.length || 1,
- enabled: pledgeModerationReady && featuredPledgeCoords.length > 0,
- });
-
- const orderedFeaturedPledges = useMemo(() => {
- if (!featuredPledges || !pledgeModerationReady) return [];
- const order = pledgeModeration.featuredOrder;
- return [...featuredPledges]
- .sort((a, b) => {
- const aCoord = getPledgeCoord(a);
- const bCoord = getPledgeCoord(b);
- return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0);
- })
- .slice(0, MAX_FEATURED_PER_SECTION);
- }, [featuredPledges, pledgeModeration, pledgeModerationReady]);
-
- const isLoading =
- !pledgeModerationReady ||
- (featuredPledgeCoords.length > 0 && featuredPledgesLoading);
-
- // Same silent-collapse rule as the other two sections: once we know
- // there's nothing featured, drop the heading rather than render an
- // empty container.
- if (pledgeModerationReady && featuredPledgeCoords.length === 0) return null;
-
- return (
-
-
-
- {isLoading ? (
-
- ) : (
- // Defensive — featured coords resolved to a non-empty set but the
- // events didn't come back (e.g. relay miss). Collapse silently.
- null
- )}
-
- );
-}
-
-function PledgeSkeleton() {
- return (
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-// Re-exported so AppRouter's lazy import shape stays identical.
export default CampaignsPage;
diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx
index 0bfc8595..5e7a6154 100644
--- a/src/pages/CommunitiesPage.tsx
+++ b/src/pages/CommunitiesPage.tsx
@@ -7,31 +7,41 @@ import { ChevronDown, ChevronUp, EyeOff, Globe2, HandHeart, PlusCircle, Users }
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
-import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
-import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
+import {
+ CommunityMiniCard,
+ CommunityMiniCardSkeleton,
+} from '@/components/discovery/CommunityMiniCard';
+import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection';
import { ModeratorCollapsibleSection } from '@/components/moderation';
import { COOL_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useDebounce } from '@/hooks/useDebounce';
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
-import { useNip50Search, type Nip50Sort } from '@/hooks/useNip50Search';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { useToast } from '@/hooks/useToast';
import { useUserOrganizations } from '@/hooks/useUserOrganizations';
import { formatSatsShort } from '@/lib/formatCampaignAmount';
-import { COMMUNITY_DEFINITION_KIND, parseCommunityEvent, type ParsedCommunity } from '@/lib/communityUtils';
-
-// ─── Page ──────────────────────────────────────────────────────────────────────
+import type { ParsedCommunity } from '@/lib/communityUtils';
+/**
+ * Dedicated `/groups` page.
+ *
+ * Thin shell around the shared {@link GroupsDiscoverySection}: hero,
+ * optional "My groups" shelf, the unified search-and-discover
+ * section, and a moderator-only Hidden collapsible.
+ *
+ * URL state (`?q=&sort=`) lives inside the section's
+ * `useDiscoveryFilters` hook so search results stay shareable. The
+ * page only owns the Show-hidden flag and the moderator-only data
+ * needed for the Hidden collapsible.
+ */
export function CommunitiesPage() {
const { t } = useTranslation();
const { config } = useAppContext();
@@ -62,234 +72,46 @@ export function CommunitiesPage() {
navigate('/groups/new');
};
- // On-page NIP-50 search + sort + show-hidden toolbar state.
- //
- // Default sort, empty query → curated featured-first idle view.
- // If no groups are featured yet, falls back to the chronological
- // "all groups" grid so the page is never blank.
- // Default sort, with query → relay search for kind 34550, results
- // post-filtered against name/description/content client-side.
- // Top / New → always active. Top sends `sort:top`;
- // New sends a raw chronological feed of the kind.
- //
- // Groups aren't country-scoped on the discovery surface (a community
- // is its own scope), so the country picker is intentionally omitted
- // from the toolbar here even though Campaigns and Pledges expose it.
- const [searchInput, setSearchInput] = useState('');
- const [sortMode, setSortMode] = useState('default');
const [showHidden, setShowHidden] = useState(false);
- const debouncedSearch = useDebounce(searchInput, 300);
- const trimmedSearch = debouncedSearch.trim();
- const {
- data: searchHitsRaw,
- isFetching: isSearchFetching,
- isActive: isSearching,
- } = useNip50Search({
- kind: COMMUNITY_DEFINITION_KIND,
- query: debouncedSearch,
- sort: sortMode,
- parse: parseCommunityEvent,
- // Group names and descriptions live in tags, not `content`. Relay
- // NIP-50 implementations that only match content silently miss
- // obvious title hits — widen client-side by also checking these
- // tag values.
- getKeywordHaystack: (event) => {
- const name = event.tags.find(([n]) => n === 'name')?.[1] ?? '';
- const description = event.tags.find(([n]) => n === 'description')?.[1] ?? '';
- return [name, description, event.content];
- },
- });
- // Lift org moderation to the page so search results can drop hidden
- // groups (or include them when the Show-hidden switch is on).
- const { data: orgModeration, isReady: orgModerationReady } = useOrganizationModeration();
- const { searchHits, searchHiddenCount } = useMemo(() => {
- if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
- const hiddenCoords = orgModeration?.hiddenCoords ?? new Set();
- let hidden = 0;
- const visible: ParsedCommunity[] = [];
- for (const c of searchHitsRaw) {
- if (hiddenCoords.has(c.aTag)) {
- hidden += 1;
- if (showHidden) visible.push(c);
- } else {
- visible.push(c);
- }
- }
- return { searchHits: visible, searchHiddenCount: hidden };
- }, [searchHitsRaw, orgModeration, showHidden]);
-
- // The full kind-34550 fetch is only used by moderators — it feeds the
- // "Hidden" collapsible section and the hidden-count badge inside the
- // toolbar's filter menu. Non-moderators don't need it: the unified
- // section renders moderator-featured groups directly (or relay
- // search results when the user is actively browsing), so there's no
- // "render everything, then filter for the Agora tag" round-trip and
- // therefore no flash of unrelated groups before the curated set
- // appears.
+ // Moderator-only: fetch the full kind-34550 universe so we can list
+ // hidden groups and surface a hidden-count badge on the toolbar.
+ // Non-moderators don't need this query — the section drives the
+ // public idle/active grids straight from featured + search.
const { data: allOrgs, isLoading: allOrgsLoading } = useDiscoverCommunities({
limit: 200,
enabled: isMod,
});
- const { allHiddenCount, hiddenGroups } = useMemo(() => {
+ const { data: orgModeration } = useOrganizationModeration();
+ const { hiddenGroups, hiddenCount } = useMemo(() => {
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set();
- let hidden = 0;
- const hiddenList: ParsedCommunity[] = [];
+ const list: ParsedCommunity[] = [];
for (const org of allOrgs ?? []) {
- if (hiddenCoords.has(org.aTag)) {
- hidden += 1;
- hiddenList.push(org);
- }
+ if (hiddenCoords.has(org.aTag)) list.push(org);
}
- return { allHiddenCount: hidden, hiddenGroups: hiddenList };
+ return { hiddenGroups: list, hiddenCount: list.length };
}, [allOrgs, orgModeration]);
- // Featured groups — the curated list moderators publish. This is the
- // entire idle-mode payload: no chronological fallback, no client-side
- // tag filter, no "fetch everything and pick the Agora ones out of it"
- // dance. Hidden coords are dropped (unless a moderator has flipped
- // Show hidden on).
- const {
- data: featuredOrgs,
- isLoading: featuredOrgsLoading,
- } = useFeaturedOrganizations();
- const featuredGroups = useMemo(() => {
- if (!featuredOrgs) return [];
- const hiddenCoords = orgModeration?.hiddenCoords ?? new Set();
- return featuredOrgs
- .map((entry) => entry.community)
- .filter((c) => (isMod && showHidden) || !hiddenCoords.has(c.aTag));
- }, [featuredOrgs, orgModeration, isMod, showHidden]);
-
- // Idle-render skeleton gate. `useFeaturedOrganizations` is internally
- // gated on `moderationReady`, so while the moderation labels are
- // still loading, the hook is *disabled* and reports `isLoading: false`
- // / `data: undefined`. Treating that as "not loading" would render
- // the empty state for a moment before the curated grid pops in;
- // tracking moderation-readiness here keeps the skeleton on screen
- // until we know what's featured.
- const idleLoading = !orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined;
-
- // Search + sort + show-hidden cluster for the unified section.
- const searchToolbar = (
-
- );
-
return (
-
+
+
- {/* Unified Groups section.
- - Idle (no search / no sort): renders ONLY moderator-featured
- groups. No fallback to a chronological "all groups" grid —
- that produced a brief flash of unrelated communities while
- the relay returned every kind-34550 event before the
- client-side Agora-tag filter ran. The skeleton is gated on
- the featured query itself, so the idle view goes
- skeleton → curated grid without any intermediate state.
- - Active (search / Top / New): renders the full relay
- search result set. */}
-
-
-
-
- )}
-
-
{isMod && (
}
@@ -495,7 +317,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
}
// ═══════════════════════════════════════════════════════════════════════════════
-// Community shelves
+// "My groups" shelf
// ═══════════════════════════════════════════════════════════════════════════════
type UserOrganizationsResult = ReturnType;
@@ -508,15 +330,17 @@ function MyCommunitiesShelf({
const { t } = useTranslation();
const { user } = useCurrentUser();
// "My organizations" = orgs the user founded, moderates, or follows.
- // Sorting is founder first, moderator second, followed-only last, with
- // newest community definition revisions first inside each bucket.
+ // Sorting is founder first, moderator second, followed-only last,
+ // with newest community definition revisions first inside each
+ // bucket.
const { data: organizations } = userOrganizations;
const [expanded, setExpanded] = useState(false);
if (!user) return null;
- // Suppress the entire section (header + tagline included) until at least
- // one group is known. Rendering the header while the query is still
- // pending causes a flash when the result resolves to an empty list.
+ // Suppress the entire section (header + tagline included) until at
+ // least one group is known. Rendering the header while the query is
+ // still pending causes a flash when the result resolves to an empty
+ // list.
if (!organizations || organizations.length === 0) return null;
const COLLAPSED_COUNT = 4;
@@ -570,7 +394,3 @@ function MyCommunitiesShelf({
);
}
-
-
-
-