Extract three reusable discovery sections shared by home and dedicated pages

The home page now shows the same Campaigns / Groups / Pledges sections
as their dedicated pages (/campaigns/all, /groups, /pledges), with the
same titles, taglines, and search/sort/country toolbars instead of
'Browse all' shortcut links. Each surface's discovery logic lived
in its own page and the home page was about to grow a fourth copy of
it, so the section bodies move into reusable components:

  CampaignsDiscoverySection  src/components/discovery/
  GroupsDiscoverySection
  PledgesDiscoverySection

Each owns the section header (title / tagline switch on active
search), the DiscoverySearchToolbar, the idle featured grid, the
active search/sort/country grid, and the per-section empty / no-match
cards. Filter state (search input, sort, country, debouncing) lives
in a new useDiscoveryFilters hook which has two modes:

  filterPersistence='url'   - flat ?q=&sort=&country= params. Used by
                              the dedicated pages so search results
                              are shareable and survive refresh.
  filterPersistence='local' - local-only state. Used by / where three
                              sections coexist and can't all own ?q=.
                              Refreshing the home lands on the curated
                              idle view, which matches what we want.

The dedicated pages keep their hero, optional Your-X shelf, and the
moderator-only Hidden collapsible — those stay page-level because
each page wants its own copy. They drive the section's Show-hidden
toolbar switch via a hoisted prop so the page-level Hidden
collapsible can read the same flag.

Side effects:

  - ActionShareMenu moves from inside ActionsPage to its own file
    so PledgesDiscoverySection can render it on every card without
    re-importing the page module.

  - useDiscoverCommunities is unchanged but only the dedicated
    /groups page calls it now (for the Hidden collapsible /
    hidden-count badge). The home page never triggers it.

  - browseAllGroups and browseAllPledges locale keys drop from all
    16 locales since the launchpad layout that needed them no
    longer exists.
This commit is contained in:
lemon
2026-05-28 14:05:34 -07:00
parent ba2ab78995
commit a358a5d95c
25 changed files with 1493 additions and 1358 deletions
+196
View File
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import {
Check,
Link as LinkIcon,
Loader2,
MoreHorizontal,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ModerationMenuItems } from '@/components/moderation';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import type { Action } from '@/hooks/useActions';
function getPledgeCoord(action: Action) {
return `36639:${action.pubkey}:${action.id}`;
}
/**
* Per-card kebab menu for pledges. Surfaces:
* • Delete (owner only) — NIP-09 with both `e` and `a` tags so
* relays that ignore a-tag-only deletions still drop the event.
* • Copy link — naddr1 URL on the current share origin.
* • Moderation actions (mods only) — hide / feature, under a
* separator that only renders when the viewer is a moderator.
*
* Lives outside `ActionsPage` so both the page and the reusable
* `PledgesDiscoverySection` can pin it to the card's `topRight` slot
* without duplicating the logic.
*/
export 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label={t('pledges.card.actionsAriaLabel')}
className="h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
{isOwner && (
<>
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{t('pledges.card.deletePledge')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleCopyLink}>
{copied ? (
<Check className="h-4 w-4 mr-2 text-primary" />
) : (
<LinkIcon className="h-4 w-4 mr-2" />
)}
{t('pledges.card.copyLink')}
</DropdownMenuItem>
{/* 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 && <DropdownMenuSeparator />}
<ModerationMenuItems
coord={getPledgeCoord(action)}
entityTitle={displayTitle}
surface="pledge"
axes={['hide', 'featured']}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,266 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { 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 { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
import type { Nip50Sort } from '@/hooks/useNip50Search';
import type { ParsedCampaign } from '@/lib/campaign';
/**
* 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 section
* handles the "show featured only" framing on top of that.
*/
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none');
interface CampaignsDiscoverySectionProps {
/**
* Where this section's filter state lives:
*
* • `'url'` — flat URL params (`?q=&sort=&country=`). Used by the
* dedicated `/campaigns/all` page so search results are
* shareable and survive refresh.
* • `'local'` — local-only state. Used by `/` where three
* discovery sections coexist and can't all own `?q=`.
*/
filterPersistence: 'url' | 'local';
/**
* Visible-row cap for the **idle** featured-first view. The active
* (search / sort / country) view always shows the full result set,
* because the user has explicitly asked to browse. Defaults to
* unlimited (`undefined`).
*/
idleLimit?: number;
/**
* Optional hoisted Show-hidden state. When provided, the toolbar
* exposes the mod-only switch and uses this state. The page can
* read the same value to drive a separate Hidden collapsible. When
* omitted, the switch never appears.
*/
showHidden?: {
value: boolean;
onChange: (next: boolean) => void;
/** Hidden-count badge for the toolbar chip. */
count?: number;
};
}
/**
* Unified campaigns discovery section: section header + toolbar +
* idle/active grid.
*
* The section has two display modes:
*
* 1. **Idle** (no search, no sort, no country picked) — renders the
* moderator-curated featured grid. Falls back to the
* chronological grid when nothing is featured yet so the section
* is never blank.
* 2. **Active** — renders the full ranked / chronological / country-
* scoped result set.
*
* Hidden campaigns are excluded by default. Moderators can flip the
* Show-hidden switch in the toolbar; the section reads that state
* from the `showHidden` prop so a page can persist it across
* multiple shelves (e.g. the Hidden collapsible mod section).
*
* Search is post-filtered client-side across title / summary / story /
* location / categories — relay NIP-50 sort-by-top doesn't account
* for sats raised, which is the ranking signal users actually want
* when searching for campaigns.
*/
export function CampaignsDiscoverySection({
filterPersistence,
idleLimit,
showHidden: showHiddenProp,
}: CampaignsDiscoverySectionProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const filters = useDiscoveryFilters({
urlPrefix: filterPersistence === 'url' ? '' : undefined,
enableCountry: true,
});
const activeQuery = filters.debouncedSearch.trim();
const isActive =
activeQuery !== '' || filters.sort !== 'default' || !!filters.country;
const { data: campaigns, isLoading } = useAllCampaigns({
sort: toQuerySort(filters.sort),
search: activeQuery,
countryCode: filters.country,
limit: 200,
});
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
// Featured slot list — derived from moderation labels. Hidden
// coords dropped so a featured-then-hidden campaign disappears
// from the row.
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,
});
const showHiddenValue = showHiddenProp?.value ?? false;
// Visible campaigns in the **active** branch: every campaign
// matching the search / sort / country, minus hidden (unless the
// moderator opted in). Featured items are intentionally NOT pulled
// out — when the user is actively browsing, they want a ranked or
// chronological grid, not the curated shelf.
const visible = useMemo(() => {
const all = campaigns ?? [];
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
const out: ParsedCampaign[] = [];
for (const c of all) {
if (hiddenCoords.has(c.aTag)) {
if (isMod && showHiddenValue) out.push(c);
} else {
out.push(c);
}
}
return out;
}, [campaigns, isMod, moderation, showHiddenValue]);
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 nothing is featured, fall back
// to the latest chronological grid so the section is never blank
// when there's content to show.
const idleCampaigns = useMemo<ParsedCampaign[]>(() => {
const list =
orderedFeaturedCampaigns.length > 0 ? orderedFeaturedCampaigns : visible;
return idleLimit ? list.slice(0, idleLimit) : list;
}, [orderedFeaturedCampaigns, visible, idleLimit]);
const showSkeleton = isLoading || !moderationReady;
const listForRender = isActive ? visible : idleCampaigns;
const hiddenCount = showHiddenProp?.count ?? 0;
const hiddenAllOfThem = !isActive && hiddenCount > 0 && !showHiddenValue;
return (
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{activeQuery ? t('common.search') : t('campaigns.all.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{activeQuery
? t('common.searchResultsCount', { count: visible.length })
: t('campaigns.all.sectionTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={filters.searchInput}
onQueryChange={filters.setSearchInput}
sort={filters.sort}
onSortChange={filters.setSort}
sortOptions={['top', 'new']}
searchPlaceholderKey="campaigns.all.searchPlaceholder"
searchAriaLabelKey="campaigns.all.searchAriaLabel"
showHidden={
isMod && showHiddenProp
? {
value: showHiddenProp.value,
onChange: showHiddenProp.onChange,
count: hiddenCount,
}
: undefined
}
country={filters.country}
onCountryChange={filters.setCountry}
/>
</div>
{showSkeleton ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
) : listForRender.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-4">
<HandHeart className="size-10 text-muted-foreground mx-auto" />
<div className="space-y-1.5">
{activeQuery ? (
<>
<h3 className="text-lg font-semibold">
{t('campaigns.all.noMatch', { query: activeQuery })}
</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.noMatchHint')}
</p>
</>
) : hiddenAllOfThem ? (
<>
<h3 className="text-lg font-semibold">
{t('campaigns.all.allHidden')}
</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.allHiddenHint')}
</p>
</>
) : (
<>
<h3 className="text-lg font-semibold">
{t('campaigns.all.empty')}
</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.emptyHint')}
</p>
</>
)}
</div>
<Button asChild>
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.all.startCampaign')}
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{listForRender.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,253 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card';
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
import {
CommunityMiniCard,
CommunityMiniCardSkeleton,
} from '@/components/discovery/CommunityMiniCard';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
import { useNip50Search } from '@/hooks/useNip50Search';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
type ParsedCommunity,
} from '@/lib/communityUtils';
interface GroupsDiscoverySectionProps {
/**
* Where this section's filter state lives. See
* `CampaignsDiscoverySection` for the rationale.
*/
filterPersistence: 'url' | 'local';
/**
* Visible-row cap for the **idle** featured-first view. The active
* (search / sort) view always shows the full result set. Defaults
* to unlimited (`undefined`).
*/
idleLimit?: number;
/**
* Optional hoisted Show-hidden state. When provided, the toolbar
* exposes the mod-only switch and uses this state. The page can
* read the same value to drive a separate Hidden collapsible.
*/
showHidden?: {
value: boolean;
onChange: (next: boolean) => void;
/** Hidden-count badge for the toolbar chip. */
count?: number;
};
}
/**
* Unified groups discovery section: section header + toolbar +
* idle/active grid.
*
* • **Idle** (default sort, empty query) — renders ONLY
* moderator-featured groups. No fallback to a chronological "all
* groups" grid: that produced a 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 an intermediate state.
*
* • **Active** (search / Top / New) — renders the full relay
* search result set, post-filtered against name / description /
* content client-side because group names live in tags and most
* NIP-50 relays only match `content`.
*
* Groups aren't country-scoped (a community is its own scope), so
* the country picker is intentionally omitted from the toolbar even
* though Campaigns and Pledges expose it.
*/
export function GroupsDiscoverySection({
filterPersistence,
idleLimit,
showHidden: showHiddenProp,
}: GroupsDiscoverySectionProps) {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const filters = useDiscoveryFilters({
urlPrefix: filterPersistence === 'url' ? '' : undefined,
enableCountry: false,
});
const trimmedSearch = filters.debouncedSearch.trim();
const showHiddenValue = showHiddenProp?.value ?? false;
const hiddenCount = showHiddenProp?.count ?? 0;
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<ParsedCommunity>({
kind: COMMUNITY_DEFINITION_KIND,
query: filters.debouncedSearch,
sort: filters.sort,
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];
},
});
const { data: orgModeration, isReady: orgModerationReady } =
useOrganizationModeration();
const searchHits = useMemo(() => {
if (!searchHitsRaw) return undefined;
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
const visible: ParsedCommunity[] = [];
for (const c of searchHitsRaw) {
if (hiddenCoords.has(c.aTag)) {
if (showHiddenValue) visible.push(c);
} else {
visible.push(c);
}
}
return visible;
}, [searchHitsRaw, orgModeration, showHiddenValue]);
// 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<ParsedCommunity[]>(() => {
if (!featuredOrgs) return [];
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
const list = featuredOrgs
.map((entry) => entry.community)
.filter((c) => (isMod && showHiddenValue) || !hiddenCoords.has(c.aTag));
return idleLimit ? list.slice(0, idleLimit) : list;
}, [featuredOrgs, orgModeration, isMod, showHiddenValue, idleLimit]);
// 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;
return (
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch ? t('common.search') : t('groups.list.allGroups')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('groups.list.allGroupsTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={filters.searchInput}
onQueryChange={filters.setSearchInput}
sort={filters.sort}
onSortChange={filters.setSort}
sortOptions={['top', 'new']}
searchPlaceholderKey="groups.list.searchPlaceholder"
searchAriaLabelKey="groups.list.searchAriaLabel"
showHidden={
isMod && showHiddenProp
? {
value: showHiddenProp.value,
onChange: showHiddenProp.onChange,
count: hiddenCount,
}
: undefined
}
/>
</div>
{isSearching ? (
<>
{isSearchFetching && !searchHits ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : searchHits && searchHits.length > 0 ? (
<CommunityGrid>
{searchHits.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('groups.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('groups.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
)}
</CardContent>
</Card>
)}
</>
) : idleLoading ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : featuredGroups.length > 0 ? (
<CommunityGrid>
{featuredGroups.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
</CardContent>
</Card>
)}
</section>
);
}
@@ -0,0 +1,328 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionShareMenu } from '@/components/ActionShareMenu';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { ModerationOverlay } from '@/components/moderation';
import { PledgeCard } from '@/components/PledgeCard';
import { parseAction, useActions, type Action } from '@/hooks/useActions';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
import { useNip50Search } from '@/hooks/useNip50Search';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
function getPledgeCoord(action: Action) {
return `36639:${action.pubkey}:${action.id}`;
}
function ActionSkeleton() {
return (
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="flex-1 p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
);
}
interface PledgesDiscoverySectionProps {
/**
* Where this section's filter state lives. See
* `CampaignsDiscoverySection` for the rationale.
*/
filterPersistence: 'url' | 'local';
/**
* Visible-row cap for the **idle** featured-first view. The active
* (search / sort / country) view always shows the full result set.
* Defaults to unlimited (`undefined`).
*/
idleLimit?: number;
/**
* Optional hoisted Show-hidden state. When provided, the toolbar
* exposes the mod-only switch and uses this state. The page can
* read the same value to drive a separate Hidden collapsible.
*/
showHidden?: {
value: boolean;
onChange: (next: boolean) => void;
/** Hidden-count badge for the toolbar chip. */
count?: number;
};
}
/**
* Unified pledges discovery section: section header + toolbar +
* idle/active grid.
*
* • **Idle** (no search / no sort / no country) — renders the
* moderator-featured pledges, falling back to chronological
* all-pledges when nothing is featured yet.
*
* • **Active** — renders the full search / sort / country-scoped
* result set, post-filtered against title / content client-side.
* Picking a country with an empty query still activates the
* search view — narrowing kind 36639 by NIP-73 `iso3166:XX` +
* legacy `geo:XX` tags produces a useful filtered grid even
* without a typed term.
*/
export function PledgesDiscoverySection({
filterPersistence,
idleLimit,
showHidden: showHiddenProp,
}: PledgesDiscoverySectionProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: btcPrice } = useBtcPrice();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const filters = useDiscoveryFilters({
urlPrefix: filterPersistence === 'url' ? '' : undefined,
enableCountry: true,
});
const trimmedSearch = filters.debouncedSearch.trim();
const showHiddenValue = showHiddenProp?.value ?? false;
const canShowHidden = isMod && showHiddenValue;
const hiddenCount = showHiddenProp?.count ?? 0;
// Country → NIP-73 `#i` tag list. Picking a country with no typed
// query still activates the search view; narrowing a kind by
// external identifier produces a useful filtered grid even without
// a typed term.
const iTags = useMemo<string[] | undefined>(() => {
if (!filters.country) return undefined;
const code = filters.country.toUpperCase();
return [`iso3166:${code}`, `geo:${code}`];
}, [filters.country]);
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<Action>({
kind: 36639,
query: filters.debouncedSearch,
sort: filters.sort,
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];
},
});
const { data: rawActions, isLoading: actionsLoading } = useActions({
countryCode: filters.country,
limit: 300,
});
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),
);
}, [pledgeModeration, pledgeModerationReady]);
const { data: featuredPledges } = useActions({
coordinates: featuredPledgeCoords,
limit: featuredPledgeCoords.length || 1,
enabled: pledgeModerationReady && featuredPledgeCoords.length > 0,
});
const orderedFeaturedPledges = useMemo(() => {
if (!featuredPledges || !pledgeModerationReady) 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, pledgeModerationReady]);
const featuredPledgeCoordSet = useMemo(
() => new Set(featuredPledgeCoords),
[featuredPledgeCoords],
);
const searchHits = useMemo(() => {
if (!searchHitsRaw) return undefined;
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
const visible: Action[] = [];
for (const a of searchHitsRaw) {
const coord = getPledgeCoord(a);
if (hiddenCoords.has(coord)) {
if (canShowHidden) visible.push(a);
} else {
visible.push(a);
}
}
return visible;
}, [searchHitsRaw, pledgeModeration, canShowHidden]);
// 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 = useMemo(() => {
if (!rawActions) return undefined;
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
const visible: Action[] = [];
for (const action of rawActions) {
const coord = getPledgeCoord(action);
if (hiddenCoords.has(coord)) {
if (canShowHidden) visible.push(action);
} else {
visible.push(action);
}
}
return visible;
}, [rawActions, pledgeModeration, canShowHidden]);
const isLoading = actionsLoading || !pledgeModerationReady;
const isSearchLoading = isSearchFetching || !pledgeModerationReady;
// Idle list: featured first; if none are featured, fall back to
// the chronological all-pledges grid so the section is never blank.
const idlePledges = useMemo<Action[]>(() => {
const list =
orderedFeaturedPledges.length > 0
? orderedFeaturedPledges
: (actions ?? []).filter(
(action) => !featuredPledgeCoordSet.has(getPledgeCoord(action)),
);
return idleLimit ? list.slice(0, idleLimit) : list;
}, [orderedFeaturedPledges, actions, featuredPledgeCoordSet, idleLimit]);
const renderPledge = (action: Action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={getPledgeCoord(action)}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
);
return (
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch ? t('common.search') : t('pledges.list.allPledges')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('pledges.list.allPledgesTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={filters.searchInput}
onQueryChange={filters.setSearchInput}
sort={filters.sort}
onSortChange={filters.setSort}
sortOptions={['top', 'new']}
searchPlaceholderKey="pledges.list.searchPlaceholder"
searchAriaLabelKey="pledges.list.searchAriaLabel"
showHidden={
isMod && showHiddenProp
? {
value: showHiddenProp.value,
onChange: showHiddenProp.onChange,
count: hiddenCount,
}
: undefined
}
country={filters.country}
onCountryChange={filters.setCountry}
/>
</div>
{isSearching ? (
<>
{isSearchLoading && !searchHits ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<ActionSkeleton key={i} />
))}
</div>
) : searchHits && searchHits.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{searchHits.map(renderPledge)}
</div>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('pledges.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('pledges.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('pledges.list.emptyTitle')}
</p>
)}
</div>
</Card>
)}
</>
) : isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<ActionSkeleton key={i} />
))}
</div>
) : idlePledges.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{idlePledges.map(renderPledge)}
</div>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">
{t('pledges.list.emptyTitle')}
</p>
</div>
</Card>
)}
</section>
);
}
+198
View File
@@ -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 `<input>` 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<Nip50Sort>(
useUrl ? parseSort(searchParams.get(sortKey)) : 'default',
);
const [country, setCountryState] = useState<string | undefined>(
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,
};
}
-2
View File
@@ -692,8 +692,6 @@
"community": "حملات المجتمع",
"communityDesc": "ساعد في تمويل التغييرات التي تستحق العناء.",
"browseAll": "تصفّح كل الحملات ←",
"browseAllGroups": "تصفّح كل المجموعات ←",
"browseAllPledges": "تصفّح كل التعهدات ←",
"pending": "بانتظار الموافقة",
"pendingDesc": "حملات موجودة على الشبكة لم يوافق عليها ولم يُخفها أي مشرف من فريق Soapbox بعد.",
"pendingEmpty": "لا يوجد شيء بانتظار المراجعة.",
-2
View File
@@ -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",
-2
View File
@@ -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.",
-2
View File
@@ -704,8 +704,6 @@
"community": "کمپین‌های جامعه",
"communityDesc": "به تأمین مالی تغییراتی که ارزش انجام دادن دارند کمک کنید.",
"browseAll": "← مرور همه کمپین‌ها",
"browseAllGroups": "← مرور همه گروه‌ها",
"browseAllPledges": "← مرور همه تعهدها",
"pending": "در انتظار تأیید",
"pendingDesc": "کمپین‌هایی که در شبکه هستند و هیچ ناظر تیم Soapbox هنوز آن‌ها را تأیید یا پنهان نکرده است.",
"pendingEmpty": "چیزی برای بررسی نیست.",
-2
View File
@@ -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.",
-2
View File
@@ -1136,8 +1136,6 @@
"community": "कम्युनिटी कैंपेन",
"communityDesc": "उन बदलावों को फंड करने में मदद करें जिनके होने लायक़ हैं।",
"browseAll": "सभी कैंपेन देखें →",
"browseAllGroups": "सभी ग्रुप देखें →",
"browseAllPledges": "सभी प्लेज देखें →",
"pending": "मंज़ूरी का इंतज़ार",
"pendingDesc": "नेटवर्क पर ऐसे कैंपेन जिन्हें Team Soapbox के किसी मॉडरेटर ने अभी मंज़ूरी या छुपाया नहीं है।",
"pendingEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।",
-2
View File
@@ -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.",
-2
View File
@@ -704,8 +704,6 @@
"community": "យុទ្ធនាការសហគមន៍",
"communityDesc": "ជួយផ្ដល់មូលនិធិដល់ការផ្លាស់ប្ដូរដែលសក្តិសម។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
"browseAllGroups": "មើលក្រុមទាំងអស់ →",
"browseAllPledges": "មើលការសន្យាទាំងអស់ →",
"pending": "កំពុងរង់ចាំការអនុម័ត",
"pendingDesc": "យុទ្ធនាការនៅលើបណ្ដាញដែលគ្មានអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត ឬលាក់នៅឡើយ។",
"pendingEmpty": "គ្មានអ្វីរង់ចាំការពិនិត្យ។",
-2
View File
@@ -704,8 +704,6 @@
"community": "د ټولنې کمپاینونه",
"communityDesc": "د هغو بدلونونو په تمویل کې مرسته وکړئ چې وړ دي.",
"browseAll": "← ټول کمپاینونه وګورئ",
"browseAllGroups": "← ټولې ډلې وګورئ",
"browseAllPledges": "← ټولې ژمنې وګورئ",
"pending": "د منلو په تمه",
"pendingDesc": "هغه کمپاینونه چې په شبکه کې شته، خو د Soapbox ټیم هیڅ مدیر یې لا تر اوسه نه دی منلی او نه پټ کړی.",
"pendingEmpty": "د بیاکتنې لپاره څه نشته.",
-2
View File
@@ -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.",
-2
View File
@@ -1136,8 +1136,6 @@
"community": "Кампании сообщества",
"communityDesc": "Помогите финансировать изменения, которые стоит сделать.",
"browseAll": "Просмотреть все кампании →",
"browseAllGroups": "Просмотреть все группы →",
"browseAllPledges": "Просмотреть все обещания →",
"pending": "Ожидают одобрения",
"pendingDesc": "Кампании в сети, которые ещё не были одобрены или скрыты модератором Team Soapbox.",
"pendingEmpty": "Ничего не ждёт проверки.",
-2
View File
@@ -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.",
-2
View File
@@ -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.",
-2
View File
@@ -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.",
-2
View File
@@ -704,8 +704,6 @@
"community": "社群活動",
"communityDesc": "為值得做的改變提供資金。",
"browseAll": "瀏覽所有活動 →",
"browseAllGroups": "瀏覽所有群組 →",
"browseAllPledges": "瀏覽所有懸賞 →",
"pending": "等待審批",
"pendingDesc": "網路上尚未被任何 Soapbox 團隊版主批准或隱藏的活動。",
"pendingEmpty": "沒有等待審查的內容。",
-2
View File
@@ -704,8 +704,6 @@
"community": "社区活动",
"communityDesc": "为值得做的改变提供资金。",
"browseAll": "浏览所有活动 →",
"browseAllGroups": "浏览所有群组 →",
"browseAllPledges": "浏览所有悬赏 →",
"pending": "等待审批",
"pendingDesc": "网络上尚未被任何 Soapbox 团队版主批准或隐藏的活动。",
"pendingEmpty": "没有等待审查的内容。",
+110 -469
View File
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label={t('pledges.card.actionsAriaLabel')}
className="h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
{isOwner && (
<>
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{t('pledges.card.deletePledge')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleCopyLink}>
{copied ? (
<Check className="h-4 w-4 mr-2 text-primary" />
) : (
<LinkIcon className="h-4 w-4 mr-2" />
)}
{t('pledges.card.copyLink')}
</DropdownMenuItem>
{/* 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 && <DropdownMenuSeparator />}
<ModerationMenuItems
coord={getPledgeCoord(action)}
entityTitle={displayTitle}
surface="pledge"
axes={['hide', 'featured']}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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<string | undefined>(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<Nip50Sort>('default');
const [showHidden, setShowHidden] = useState(false);
const debouncedSearch = useDebounce(searchInput, 300);
const trimmedSearch = debouncedSearch.trim();
const iTags = useMemo<string[] | undefined>(() => {
if (!selectedCountry) return undefined;
const code = selectedCountry.toUpperCase();
return [`iso3166:${code}`, `geo:${code}`];
}, [selectedCountry]);
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<Action>({
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<Action[]>(() => {
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<string>();
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<string>();
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<Action[]>(() => {
if (orderedFeaturedPledges.length > 0) return orderedFeaturedPledges;
return (actions ?? []).filter(
(action) => !featuredPledgeCoordSet.has(getPledgeCoord(action)),
);
}, [orderedFeaturedPledges, actions, featuredPledgeCoordSet]);
const hiddenPledges = useMemo<Action[]>(() => {
if (!isMod || !pledgeModerationReady) return [];
return (allPledgesForMods ?? []).filter((pledge) => pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge)));
}, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]);
const headerControls = (
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={sortMode}
onSortChange={setSortMode}
sortOptions={['top', 'new']}
searchPlaceholderKey="pledges.list.searchPlaceholder"
searchAriaLabelKey="pledges.list.searchAriaLabel"
showHidden={isMod ? {
value: canShowHidden,
onChange: setShowHidden,
count: isSearching ? searchHiddenCount : listHiddenCount,
} : undefined}
country={selectedCountry}
onCountryChange={setSelectedCountry}
/>
);
const visibleMine = showAllMine
? (myPledges ?? [])
: (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
return (
<main className="pb-16 sidebar:pb-0">
<ActionsHero
actionCount={actions?.length ?? 0}
actionCount={allPledgesForMods?.length ?? myPledges?.length ?? 0}
canCreate={!!user}
onCreateAction={() => navigate(createActionHref)}
/>
@@ -417,8 +145,12 @@ export default function ActionsPage() {
{user && myPledges && myPledges.length > 0 && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('pledges.list.myPledges')}</h2>
<p className="text-sm text-muted-foreground mt-1">{t('pledges.list.myPledgesTagline')}</p>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('pledges.list.myPledges')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('pledges.list.myPledgesTagline')}
</p>
</div>
<ActionSection
items={visibleMine}
@@ -431,131 +163,18 @@ export default function ActionsPage() {
</section>
)}
{/* 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. */}
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch
? t('common.search')
: t('pledges.list.allPledges')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('pledges.list.allPledgesTagline')}
</p>
</div>
{headerControls}
</div>
{isSearching ? (
<>
{isSearchLoading && !searchHits ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
) : searchHits && searchHits.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{searchHits.map((action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={getPledgeCoord(action)}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
))}
</div>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('pledges.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('pledges.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('pledges.list.emptyTitle')}
</p>
)}
</div>
</Card>
)}
</>
) : isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
) : idlePledges.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{idlePledges.map((action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={getPledgeCoord(action)}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
))}
</div>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-4">
<HandHeart className="size-10 text-muted-foreground mx-auto" />
<div>
<h3 className="text-lg font-semibold">{t('pledges.list.emptyTitle')}</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{selectedCountry
? t('pledges.list.emptyHintCountry', { country: selectedCountryName })
: t('pledges.list.emptyHint')}
</p>
</div>
{user && (
<Button onClick={() => navigate(createActionHref)}>
<PlusCircle className="size-4 mr-2" />
{t('pledges.list.createPledge')}
</Button>
)}
</div>
</Card>
)}
</section>
<PledgesDiscoverySection
filterPersistence="url"
showHidden={
isMod
? {
value: showHidden,
onChange: setShowHidden,
count: hiddenPledges.length,
}
: undefined
}
/>
{isMod && (
<ModeratorCollapsibleSection
@@ -567,7 +186,9 @@ export default function ActionsPage() {
emptyText={t('pledges.list.hiddenEmpty')}
skeleton={
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <ActionSkeleton key={i} />)}
{Array.from({ length: 4 }).map((_, i) => (
<ActionSkeleton key={i} />
))}
</div>
}
>
@@ -589,7 +210,10 @@ export default function ActionsPage() {
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
<ActionShareMenu
action={action}
displayTitle={action.title}
/>
</>
}
/>
@@ -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"
>
<div className="flex items-center justify-center gap-3">
<Megaphone className="size-5 text-amber-200 shrink-0 drop-shadow" aria-hidden />
<Megaphone
className="size-5 text-amber-200 shrink-0 drop-shadow"
aria-hidden
/>
<span className="text-sm sm:text-base font-semibold tracking-tight text-white drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{actionCount.toLocaleString()}
</span>
@@ -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')
}
>
<PlusCircle className="mr-2" />
{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 (
+70 -249
View File
@@ -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=<search>&country=<iso>`. 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<string>();
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<string>();
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<ParsedCampaign[]>(() => {
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 (
<main className="min-h-screen pb-16">
<AllCampaignsHero campaignCount={visible.length} />
<AllCampaignsHero campaignCount={campaigns?.length ?? 0} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-8">
{user && myCampaigns && myCampaigns.length > 0 && (
@@ -235,116 +132,38 @@ export function AllCampaignsPage() {
</section>
)}
{/* 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. */}
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{activeQuery
? t('common.search')
: t('campaigns.all.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{activeQuery
? t('common.searchResultsCount', { count: visible.length })
: t('campaigns.all.sectionTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={sort}
onSortChange={setSortFromToolbar}
sortOptions={['top', 'new']}
searchPlaceholderKey="campaigns.all.searchPlaceholder"
searchAriaLabelKey="campaigns.all.searchAriaLabel"
showHidden={isMod ? {
value: showHidden,
onChange: setShowHidden,
count: hiddenCount,
} : undefined}
country={urlCountry}
onCountryChange={setCountry}
/>
</div>
<CampaignsDiscoverySection
filterPersistence="url"
showHidden={
isMod
? {
value: showHidden,
onChange: setShowHidden,
count: hiddenCount,
}
: undefined
}
/>
{/* 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 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
) : (isActive ? visible : idleCampaigns).length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-4">
<HandHeart className="size-10 text-muted-foreground mx-auto" />
<div className="space-y-1.5">
{activeQuery ? (
<>
<h2 className="text-lg font-semibold">
{t('campaigns.all.noMatch', { query: activeQuery })}
</h2>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.noMatchHint')}
</p>
</>
) : hiddenCount > 0 && !showHidden ? (
<>
<h2 className="text-lg font-semibold">{t('campaigns.all.allHidden')}</h2>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.allHiddenHint')}
</p>
</>
) : (
<>
<h2 className="text-lg font-semibold">{t('campaigns.all.empty')}</h2>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.emptyHint')}
</p>
</>
)}
</div>
<Button asChild>
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.all.startCampaign')}
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{(isActive ? visible : idleCampaigns).map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
)}
</section>
{/* 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 && (
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
title={t('campaigns.home.hidden')}
description={t('campaigns.home.hiddenDesc')}
count={hiddenCampaigns.length}
isLoading={showSkeleton}
isLoading={!moderation}
emptyText={t('campaigns.home.hiddenEmpty')}
skeleton={
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <CampaignCardSkeleton key={i} />)}
{Array.from({ length: 4 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
}
>
@@ -355,7 +174,6 @@ export function AllCampaignsPage() {
</div>
</ModeratorCollapsibleSection>
)}
</div>
</main>
);
@@ -485,7 +303,10 @@ function AllCampaignsHero({ campaignCount }: AllCampaignsHeroProps) {
aria-live="polite"
>
<div className="flex items-center justify-center gap-3">
<HandHeart className="size-5 text-amber-200 shrink-0 drop-shadow" aria-hidden />
<HandHeart
className="size-5 text-amber-200 shrink-0 drop-shadow"
aria-hidden
/>
<span className="text-sm sm:text-base font-semibold tracking-tight text-white drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{campaignCount.toLocaleString()}
</span>
+24 -380
View File
@@ -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() {
<main className="min-h-screen pb-16">
<Hero loggedIn={!!user} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="featured">
<FeaturedCampaignsSection />
<FeaturedGroupsSection />
<FeaturedPledgesSection />
<div
className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12"
id="discover"
>
<CampaignsDiscoverySection filterPersistence="local" />
<GroupsDiscoverySection filterPersistence="local" />
<PledgesDiscoverySection filterPersistence="local" />
</div>
</main>
);
@@ -189,7 +165,7 @@ function Hero({ loggedIn }: { loggedIn: boolean }) {
asChild
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
>
<a href="#featured">{t('campaigns.home.exploreCampaigns')}</a>
<a href="#discover">{t('campaigns.home.exploreCampaigns')}</a>
</Button>
)}
</div>
@@ -199,336 +175,4 @@ function Hero({ loggedIn }: { loggedIn: boolean }) {
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Section header
// ═══════════════════════════════════════════════════════════════════════════════
function SectionHeader({
title,
description,
browseLabel,
browseHref,
}: {
title: string;
description: string;
browseLabel: string;
browseHref: string;
}) {
return (
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{title}</h2>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</div>
<Button asChild variant="ghost" size="sm" className="shrink-0">
<Link to={browseHref}>
{browseLabel}
<ArrowRight className="size-4 ms-1 rtl:rotate-180" />
</Link>
</Button>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Featured Campaigns
// ═══════════════════════════════════════════════════════════════════════════════
function FeaturedCampaignsSection() {
const { t } = useTranslation();
const { config } = useAppContext();
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
// Featured slot list — derived from moderation labels. Sorted newest-
// featured first, capped, and hidden coords removed so a
// featured-then-hidden campaign disappears from the row.
const featuredCoords = useMemo(() => {
if (!moderation) return [] as string[];
return Array.from(moderation.featuredCoords)
.filter((c) => !moderation.hiddenCoords.has(c))
.sort(
(a, b) =>
(moderation.featuredOrder.get(b) ?? 0) -
(moderation.featuredOrder.get(a) ?? 0),
)
.slice(0, MAX_FEATURED_CAMPAIGNS);
}, [moderation]);
const { data: featuredCampaigns, isLoading: featuredLoading } = useCampaigns(
moderationReady && featuredCoords.length > 0
? { coordinates: featuredCoords, limit: MAX_FEATURED_CAMPAIGNS }
: { coordinates: [], limit: MAX_FEATURED_CAMPAIGNS },
);
// Sort the fetched featured campaigns to match the newest-label order.
// `useCampaigns` returns them in network order; we want the row to match
// the moderation-label ordering.
const orderedFeatured = useMemo<ParsedCampaign[]>(() => {
if (!moderation || !featuredCampaigns) return [];
const order = moderation.featuredOrder;
return [...featuredCampaigns]
.filter((c) => featuredCoords.includes(c.aTag))
.sort((a, b) => (order.get(b.aTag) ?? 0) - (order.get(a.aTag) ?? 0))
.slice(0, MAX_FEATURED_CAMPAIGNS);
}, [featuredCampaigns, featuredCoords, moderation]);
const isLoading = !moderationReady || featuredLoading;
// Once moderation is ready and there's nothing featured, collapse the
// section silently — the home page can degrade to "hero + the sections
// that have content".
if (moderationReady && featuredCoords.length === 0) return null;
return (
<section className="space-y-5">
<SectionHeader
title={t('campaigns.home.featured')}
description={t('campaigns.home.featuredDesc', { appName: config.appName })}
browseLabel={t('campaigns.home.browseAll')}
browseHref="/campaigns/all"
/>
<FeaturedCampaignsRow
campaigns={orderedFeatured}
isLoading={isLoading}
expectedCount={featuredCoords.length}
/>
</section>
);
}
/**
* Returns the grid class string for an adaptive featured row.
* Mobile stays 1-column; desktop expands to 2/3/4 columns based on count.
* Tailwind JIT requires literal class strings, so we spell each variant
* out rather than building the class name dynamically.
*/
function featuredGridClass(n: number): string {
if (n <= 1) return 'grid grid-cols-1 gap-5';
if (n === 2) return 'grid grid-cols-1 md:grid-cols-2 gap-5';
if (n === 3) return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5';
return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5';
}
function FeaturedCampaignsRow({
campaigns,
isLoading,
expectedCount,
}: {
campaigns: ParsedCampaign[];
isLoading: boolean;
/** How many featured slots we expect once data resolves. Drives the skeleton column count. */
expectedCount: number;
}) {
if (isLoading && campaigns.length === 0) {
const skeletonCount = Math.max(
1,
Math.min(MAX_FEATURED_CAMPAIGNS, expectedCount || 2),
);
return (
<div className={featuredGridClass(skeletonCount)}>
{Array.from({ length: skeletonCount }).map((_, i) => (
<CampaignCardSkeleton
key={i}
variant={skeletonCount === 1 ? 'featured' : 'compact'}
/>
))}
</div>
);
}
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 (
<div className={featuredGridClass(campaigns.length)}>
{campaigns.map((campaign) => (
<CampaignCard
key={campaign.aTag}
campaign={campaign}
variant={useFeaturedVariant ? 'featured' : 'compact'}
/>
))}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Featured Groups
// ═══════════════════════════════════════════════════════════════════════════════
function FeaturedGroupsSection() {
const { t } = useTranslation();
const { data: orgModeration, isReady: orgModerationReady } =
useOrganizationModeration();
const { data: featuredOrgs, isLoading: featuredOrgsLoading } =
useFeaturedOrganizations();
const featuredGroups = useMemo<ParsedCommunity[]>(() => {
if (!featuredOrgs) return [];
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
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 (
<section className="space-y-5">
<SectionHeader
title={t('groups.list.featuredGroups')}
description={t('groups.list.featuredGroupsTagline')}
browseLabel={t('campaigns.home.browseAllGroups')}
browseHref="/groups"
/>
{isLoading ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : (
<CommunityGrid>
{featuredGroups.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
)}
</section>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// 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<Action[]>(() => {
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 (
<section className="space-y-5">
<SectionHeader
title={t('pledges.list.featuredPledges')}
description={t('pledges.list.featuredPledgesTagline', {
appName: config.appName,
})}
browseLabel={t('campaigns.home.browseAllPledges')}
browseHref="/pledges"
/>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({
length: Math.max(
1,
Math.min(MAX_FEATURED_PER_SECTION, featuredPledgeCoords.length || 4),
),
}).map((_, i) => (
<PledgeSkeleton key={i} />
))}
</div>
) : orderedFeaturedPledges.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{orderedFeaturedPledges.map((action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
btcPrice={btcPrice}
showAuthor
showTranslate
/>
))}
</div>
) : (
// Defensive — featured coords resolved to a non-empty set but the
// events didn't come back (e.g. relay miss). Collapse silently.
null
)}
</section>
);
}
function PledgeSkeleton() {
return (
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="flex-1 p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
);
}
// Re-exported so AppRouter's lazy import shape stays identical.
export default CampaignsPage;
+48 -228
View File
@@ -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<Nip50Sort>('default');
const [showHidden, setShowHidden] = useState(false);
const debouncedSearch = useDebounce(searchInput, 300);
const trimmedSearch = debouncedSearch.trim();
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<ParsedCommunity>({
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<string>();
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<string>();
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<ParsedCommunity[]>(() => {
if (!featuredOrgs) return [];
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
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 = (
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={sortMode}
onSortChange={setSortMode}
sortOptions={['top', 'new']}
searchPlaceholderKey="groups.list.searchPlaceholder"
searchAriaLabelKey="groups.list.searchAriaLabel"
showHidden={isMod ? {
value: showHidden,
onChange: setShowHidden,
count: isSearching ? searchHiddenCount : allHiddenCount,
} : undefined}
/>
);
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<CommunitiesHero onCreateCommunity={handleCreateCommunity} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 space-y-10 sm:space-y-12 pb-8 pt-10 lg:pt-14">
<MyCommunitiesShelf
userOrganizations={userOrganizations}
<MyCommunitiesShelf userOrganizations={userOrganizations} />
<GroupsDiscoverySection
filterPersistence="url"
showHidden={
isMod
? {
value: showHidden,
onChange: setShowHidden,
count: hiddenCount,
}
: undefined
}
/>
{/* 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. */}
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch
? t('common.search')
: t('groups.list.allGroups')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('groups.list.allGroupsTagline')}
</p>
</div>
{searchToolbar}
</div>
{isSearching ? (
<>
{isSearchFetching && !searchHits ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : searchHits && searchHits.length > 0 ? (
<CommunityGrid>
{searchHits.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('groups.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('groups.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
)}
</CardContent>
</Card>
)}
</>
) : idleLoading ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : featuredGroups.length > 0 ? (
<CommunityGrid>
{featuredGroups.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
</CardContent>
</Card>
)}
</section>
{isMod && (
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
@@ -495,7 +317,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
}
// ═══════════════════════════════════════════════════════════════════════════════
// Community shelves
// "My groups" shelf
// ═══════════════════════════════════════════════════════════════════════════════
type UserOrganizationsResult = ReturnType<typeof useUserOrganizations>;
@@ -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({
</section>
);
}