Compare commits

...

13 Commits

Author SHA1 Message Date
lemon 3e433e70cb Hoist three duplicated discovery helpers to shared modules
Three small extractions that consolidate hand-rolled copies in the
discovery surfaces. No behavior change.

- getPledgeCoord → src/lib/pledges.ts. Was defined three times
  (PledgesDiscoverySection, ActionsPage, ActionShareMenu), each with
  the same '36639:<pubkey>:<d>' template. Lifted into the existing
  pledges lib and typed structurally on { pubkey, id } so the lib
  layer doesn't take a hook dep on Action.

- parseSort + toQuerySort → exported from useDiscoveryFilters and
  useAllCampaigns respectively. AllCampaignsPage was carrying its own
  copy of both with an apologetic comment ('mirroring the one in
  useDiscoveryFilters'); CampaignsDiscoverySection had its own
  toQuerySort. One source of truth each now, with the pages and the
  section importing from the same module as the hook that consumes
  the result.

- PledgeCardSkeleton → exported from PledgeCard. Replaces two
  byte-identical ActionSkeleton components in PledgesDiscoverySection
  and ActionsPage. Naming matches the existing CampaignCardSkeleton /
  CommunityMiniCardSkeleton convention of placing the skeleton next
  to its card.
2026-05-28 16:16:05 -07:00
lemon 3525f685bb Skip wasted discovery-section queries
Two related gates on the unified discovery sections:

- PledgesDiscoverySection: the chronological useActions({ limit: 300 })
  query only feeds the idle render branch (via idlePledges), but it
  was firing in active-search mode too. Active mode renders searchHits
  from useNip50Search, which never reads rawActions. On every keystroke
  that activates search we were burning a 300-event relay round-trip
  whose results went nowhere. Gate the query on !isSearching so the
  fetch happens only when the idle branch can actually consume it.

- CampaignsDiscoverySection: align the featured-coords useCampaigns
  query's enabled flag with the pledges section's pattern. useCampaigns
  already short-circuits on an empty coordinates array, so this is
  purely about not creating an empty cache entry when moderators have
  curated nothing — but it removes a small asymmetry that would have
  made the next reviewer second-guess which pattern is intentional.
2026-05-28 16:16:05 -07:00
lemon a358a5d95c 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.
2026-05-28 16:16:05 -07:00
lemon ba2ab78995 Turn the home page into a featured-only launchpad for all three surfaces
The home page used to be the canonical browse view for campaigns: hero
plus featured row, then the full community grid, then moderator-only
Pending/Hidden sections, then a per-viewer 'Your campaigns' shelf.
With /campaigns/all, /groups, and /pledges all now hosting their own
dedicated browse views (featured + search + sort + country in one
unified section), the home page no longer needs to duplicate the
campaigns browse experience.

Rebuild / as a three-section launchpad:

  Hero  -> unchanged (HeroLightningMap, Bebas Neue tagline, brand CTAs).
  Featured campaigns  -> capped at 4, links to /campaigns/all.
  Featured groups     -> capped at 8, links to /groups.
  Featured pledges    -> capped at 8, links to /pledges.

Each section pulls its featured set from the same moderation labels
that drive the dedicated page, so what surfaces here matches what
surfaces there — just truncated. Sections with no featured items
collapse silently (no empty card) so the page degrades gracefully if
moderators only curate one or two surfaces.

Each section's skeleton respects the dependency chain that gates its
underlying query: campaigns wait on useCampaignModeration, groups on
useOrganizationModeration (because useFeaturedOrganizations is
internally gated on it), pledges on usePledgeModeration. While those
are still resolving the section renders skeleton cards rather than
flashing an empty state.

Drop the unmoderated community grid, the Pending/Hidden moderator
sections, the 'Your campaigns' shelf, and the campaign-search
toolbar from the home page. All of that now lives on /campaigns/all
where viewers actually expect to browse and filter.

Add browseAllGroups and browseAllPledges to campaigns.home in all
16 locales so each section can link out with locale-appropriate copy.
2026-05-28 16:16:05 -07:00
lemon bc7e3b8547 Keep skeleton up while moderation labels are still resolving
useFeaturedOrganizations is internally gated on moderationReady — while
the organization moderation labels are loading, the underlying query
is disabled and reports isLoading: false / data: undefined. The Groups
page was using only that isLoading flag to decide whether to show the
skeleton, so during the moderation-loading window it rendered the
empty state for a moment before the curated grid popped in.

Track moderation readiness alongside the featured query and treat any
of the three states — moderation not ready, featured query in flight,
featured data not yet defined — as loading.
2026-05-28 16:16:05 -07:00
lemon f19562cf64 Render featured groups directly without intermediate event flash
The Groups page was firing a global kind-34550 query through
useDiscoverCommunities, rendering the full results, then filtering
them client-side for the 'agora' client tag. This produced a brief
flash of unrelated communities before the curated set settled.

Drop the client-side Agora-tag filter entirely and stop using the
all-communities fetch for the idle render path. The unified Groups
section now renders moderator-featured groups directly, gated on
useFeaturedOrganizations's own loading state, so the page goes
skeleton → curated grid with no intermediate render.

useDiscoverCommunities is still called for moderators only — it
feeds the Hidden collapsible section and the hidden-count badge on
the toolbar. Non-moderators no longer trigger the global fetch at
all.
2026-05-28 16:16:05 -07:00
lemon 9244bb2f16 Merge Featured and All sections on discovery pages
Campaigns, Groups, and Pledges each previously stacked a Featured
shelf above an All-X section. Collapse them into a single section
titled simply 'Campaigns' / 'Groups' / 'Pledges' that:

- Idle (no query, no sort, no country) shows the moderator-featured
  grid. If nothing is featured yet, falls back to the chronological
  all-X grid so the page is never blank.
- Active (the user typed, picked Top/New, or chose a country) shows
  the full result set, ranked or chronological per the toolbar.

The shared toolbar drops the 'default' sort option from its dropdown
(now only Top and New). Clicking an already-active sort returns the
page to the curated idle view, giving users a clear exit affordance
now that 'default' is no longer an explicit menu choice.

Personal shelves (My pledges / My groups / Your campaigns) stay
above the unified section as separate, user-scoped lists.
2026-05-28 16:16:05 -07:00
lemon 41c3fd62ac Shorten All groups tagline 2026-05-28 16:16:05 -07:00
lemon ca4855718a Hide group shelves until content is known
Regression-of: 5607f5fa
2026-05-28 16:16:05 -07:00
lemon 749c325b91 Hide Featured headers when no events are surfaced
Regression-of: 9663b05e
2026-05-28 16:16:05 -07:00
lemon 73df1484b7 Hide My pledges header when the user has no pledges 2026-05-28 16:16:05 -07:00
lemon ad43d540e0 Hide My campaigns header when the user has no campaigns 2026-05-28 16:16:05 -07:00
lemon ad31e12803 Hide My groups header when the user has no groups 2026-05-28 16:16:05 -07:00
30 changed files with 1827 additions and 1616 deletions
+193
View File
@@ -0,0 +1,193 @@
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 { getPledgeCoord } from '@/lib/pledges';
import type { Action } from '@/hooks/useActions';
/**
* 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>
);
}
@@ -133,7 +133,14 @@ export function DiscoverySearchToolbar({
key={value}
checked={sort === value}
onCheckedChange={(checked) => {
// `checked === false` means the user clicked the
// currently-active item — return to the curated
// `default` view (featured-first) rather than leaving
// them stuck on Top/New with no exit affordance now
// that `default` is no longer an exposed option in the
// dropdown.
if (checked) onSortChange(value);
else onSortChange('default');
}}
// The checkbox slot on the left is hidden in favour of an
// explicit `Check` on the right (matches the
+28
View File
@@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools';
import { AuthorByline } from '@/components/AuthorByline';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { parseAction, type Action } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
@@ -172,3 +173,30 @@ export function PledgeCard({
</Link>
);
}
/**
* Loading placeholder that matches `PledgeCard`'s grid-variant shape:
* 16:9 cover, then title, two lines of body, a progress bar row, and
* a footer line. Sized to slot into the same `<DiscoveryGrid>` / 4-col
* grids as the real card so the skeleton row doesn't reflow when data
* arrives.
*
* Lives next to `PledgeCard` for parity with `CampaignCardSkeleton`
* and `CommunityMiniCardSkeleton`, which sit next to their cards too.
* Was duplicated as `ActionSkeleton` in `PledgesDiscoverySection` and
* `ActionsPage` before this consolidation.
*/
export function PledgeCardSkeleton() {
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>
);
}
@@ -0,0 +1,261 @@
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, toQuerySort } 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 { ParsedCampaign } from '@/lib/campaign';
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,
// Mirrors the pledges section's pattern: don't enable the query
// when there are no coords to fetch. `useCampaigns` already
// short-circuits internally on an empty `coordinates` array, so
// this is purely about not creating a meaningless cache entry.
enabled: moderationReady && featuredCoords.length > 0,
});
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,318 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionShareMenu } from '@/components/ActionShareMenu';
import { Card } from '@/components/ui/card';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { ModerationOverlay } from '@/components/moderation';
import { PledgeCard, PledgeCardSkeleton } 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';
import { getPledgeCoord } from '@/lib/pledges';
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];
},
});
// Chronological feed that backs the idle grid (and the
// featured-then-chronological fallback). Gated on `!isSearching`
// because the search branch renders `searchHits` instead and never
// reads `rawActions` / `actions` — leaving this query enabled during
// search burns a 300-event relay round-trip on every keystroke that
// activates the search view. The idle branch is the only consumer,
// and the idle branch only renders when `!isSearching`, so this
// gate strictly removes wasted work.
const { data: rawActions, isLoading: actionsLoading } = useActions({
countryCode: filters.country,
limit: 300,
enabled: !isSearching,
});
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) => (
<PledgeCardSkeleton 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) => (
<PledgeCardSkeleton 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>
);
}
+15
View File
@@ -5,10 +5,25 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { CAMPAIGN_KIND, type ParsedCampaign } from '@/lib/campaign';
import { parseCampaignEvents } from '@/hooks/useCampaigns';
import type { Nip50Sort } from '@/hooks/useNip50Search';
/** Sort modes for the All Campaigns page. */
export type CampaignSort = 'top' | 'none';
/**
* Map the toolbar's sort vocabulary (`default` / `top` / `new`) onto
* `useAllCampaigns`'s vocabulary (`top` / `none`). `'new'` and `'default'`
* both map to `'none'` (chronological) — discovery sections apply the
* "show featured only when idle" framing on top of the chronological
* feed, so the underlying query doesn't need to distinguish them.
*
* Exported so the section component and any page-level consumer using
* the same hook stay aligned through one helper instead of two
* hand-rolled ternaries.
*/
export const toQuerySort = (s: Nip50Sort): CampaignSort =>
s === 'top' ? 'top' : 'none';
interface UseAllCampaignsOptions {
/** Sort mode. `top` ranks by total sats raised; `none` is chronological. */
sort: CampaignSort;
+10 -1
View File
@@ -12,6 +12,14 @@ import {
interface UseDiscoverCommunitiesOptions {
/** Maximum number of communities to fetch. Default: 24. */
limit?: number;
/**
* Gate the underlying query. Useful for callers that only need the
* full kind-34550 universe under a moderator role (e.g. the Hidden
* section on `/groups`); skipping the fetch for everyone else avoids
* a global relay round-trip whose results would only feed
* moderator-only UI. Defaults to `true`.
*/
enabled?: boolean;
}
/**
@@ -28,12 +36,13 @@ interface UseDiscoverCommunitiesOptions {
* the card just shows a gradient fallback.
*/
export function useDiscoverCommunities(options: UseDiscoverCommunitiesOptions = {}) {
const { limit = 24 } = options;
const { limit = 24, enabled = true } = options;
const { nostr } = useNostr();
const relay = nostr.relay(DITTO_RELAY);
return useQuery<ParsedCommunity[]>({
queryKey: ['discover-communities', limit],
enabled,
queryFn: async ({ signal }) => {
const events = await relay.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], limit }],
+204
View File
@@ -0,0 +1,204 @@
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.
*
* Exported because the dedicated discovery pages (`/campaigns/all`,
* `/pledges`) read `?sort=` independently from the section's hook to
* thread the value into ancillary derivations (hidden-list cache
* lookups, create-X href country prefills). One canonical parser
* keeps page-level and section-level reads in lockstep.
*/
export 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,
};
}
+13
View File
@@ -1,5 +1,18 @@
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
/**
* Addressable coordinate for a pledge (kind 36639): `36639:<pubkey>:<d>`.
*
* Accepts any object carrying `pubkey` and `id` so this helper stays in
* the lib layer without taking a hook dep on `Action`. Both the moderation
* label system (NIP-32 / kind 1985 `a`-tags) and the share-link generator
* (NIP-09 deletion requests, naddr encoders) hand-rolled the same string
* three times before this consolidation; one source of truth now.
*/
export function getPledgeCoord({ pubkey, id }: { pubkey: string; id: string }): string {
return `36639:${pubkey}:${id}`;
}
export function formatPledgeAmount(sats: number, btcPrice: number | undefined): string {
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
return `${formatSats(sats)} sats`;
+6 -6
View File
@@ -216,8 +216,8 @@
"myPledgesTagline": "التعهدات التي أنشأتها.",
"featuredPledges": "تعهدات مميزة",
"featuredPledgesTagline": "تعهدات يسلّط فريق {{appName}} الضوء عليها.",
"allPledges": "كل التعهدات",
"allPledgesTagline": "تصفّح كل تعهد على الشبكة.",
"allPledges": "التعهدات",
"allPledgesTagline": "مختارة من قِبل المشرفين. ابحث أو رتّب لتصفّح كل تعهد.",
"sectionActive": "التعهدات النشطة",
"sectionUpcoming": "التعهدات القادمة",
"sectionPast": "التعهدات السابقة",
@@ -347,8 +347,8 @@
"myGroupsTagline": "المجموعات التي أسستها أو تشرف عليها أو تتابعها.",
"featuredGroups": "المجموعات المميزة",
"featuredGroupsTagline": "مجموعات بارزة تستحق اهتمامك.",
"allGroups": "كل المجموعات",
"allGroupsTagline": "تصفّح مجموعات {{appName}}، أو ابحث في كل المجموعات على نوستر.",
"allGroups": "المجموعات",
"allGroupsTagline": "مختارة من قِبل المشرفين. ابحث أو رتّب لتصفّح كل مجموعة.",
"loginToSeeTitle": "سجّل الدخول لرؤية مجموعاتك",
"loginToSeeBody": "ستظهر هنا المجموعات التي أسستها أو التي تشرف عليها.",
"noGroupsTitle": "لا توجد مجموعات بعد",
@@ -704,10 +704,10 @@
"emptyHint": "كن أول من يبدأ حملة تمويل على {{appName}}. اروِ قصتك، اختر المستفيدين، وشارك الرابط."
},
"all": {
"title": "كل الحملات",
"title": "الحملات",
"seoTitle": "كل الحملات",
"description": "تصفّح كل الحملات المنشورة على Agora.",
"sectionTagline": "تصفّح كل قضيّة على الشبكة.",
"sectionTagline": "مختارة من قِبل المشرفين. ابحث أو رتّب لتصفّح الشبكة بأكملها.",
"heroKicker": "الحملات",
"heroHeading": "كل قضيّة،",
"heroHeadingLine2": "في مكان واحد.",
+6 -6
View File
@@ -644,8 +644,8 @@
"myPledgesTagline": "Pledges you've created.",
"featuredPledges": "Featured pledges",
"featuredPledgesTagline": "Pledges highlighted by the {{appName}} team.",
"allPledges": "All pledges",
"allPledgesTagline": "Browse every pledge on the network.",
"allPledges": "Pledges",
"allPledgesTagline": "Highlighted by moderators. Search or sort to browse every pledge.",
"sectionActive": "Active pledges",
"sectionUpcoming": "Upcoming pledges",
"sectionPast": "Past pledges",
@@ -779,8 +779,8 @@
"myGroupsTagline": "Groups you've founded, moderate, or follow.",
"featuredGroups": "Featured groups",
"featuredGroupsTagline": "Standout groups worth your attention.",
"allGroups": "All groups",
"allGroupsTagline": "Browse {{appName}} groups, or search across every group on Nostr.",
"allGroups": "Groups",
"allGroupsTagline": "Highlighted by moderators. Search or sort to browse every group.",
"searchPlaceholder": "Search groups\u2026",
"searchAriaLabel": "Search groups",
"noMatch": "No groups match \u201c{{query}}\u201d",
@@ -1144,10 +1144,10 @@
"emptyHint": "Be the first to start a fundraiser on {{appName}}. Tell your story, choose your beneficiaries, and share the link."
},
"all": {
"title": "All Campaigns",
"title": "Campaigns",
"seoTitle": "All campaigns",
"description": "Browse every campaign published on Agora.",
"sectionTagline": "Browse every cause on the network.",
"sectionTagline": "Highlighted by moderators. Search or sort to browse the full network.",
"heroKicker": "Campaigns",
"heroHeading": "Every cause,",
"heroHeadingLine2": "in one place.",
+6 -6
View File
@@ -220,8 +220,8 @@
"myPledgesTagline": "Promesas que has creado.",
"featuredPledges": "Promesas destacadas",
"featuredPledgesTagline": "Promesas destacadas por el equipo de {{appName}}.",
"allPledges": "Todas las promesas",
"allPledgesTagline": "Explora todas las promesas de la red.",
"allPledges": "Promesas",
"allPledgesTagline": "Destacadas por los moderadores. Busca u ordena para explorar todas las promesas.",
"sectionActive": "Promesas activas",
"sectionUpcoming": "Promesas próximas",
"sectionPast": "Promesas pasadas",
@@ -355,8 +355,8 @@
"myGroupsTagline": "Grupos que fundaste, moderas o sigues.",
"featuredGroups": "Grupos destacados",
"featuredGroupsTagline": "Grupos destacados que merecen tu atención.",
"allGroups": "Todos los grupos",
"allGroupsTagline": "Explora los grupos de {{appName}} o busca entre todos los grupos de Nostr.",
"allGroups": "Grupos",
"allGroupsTagline": "Destacados por los moderadores. Busca u ordena para explorar todos los grupos.",
"loginToSeeTitle": "Inicia sesión para ver tus grupos",
"loginToSeeBody": "Los grupos que fundaste o moderas aparecerán aquí.",
"noGroupsTitle": "Aún no hay grupos",
@@ -720,10 +720,10 @@
"noMatchHint": "Prueba con otro término de búsqueda o bórrala."
},
"all": {
"title": "Todas las campañas",
"title": "Campañas",
"seoTitle": "Todas las campañas",
"description": "Explora todas las campañas publicadas en Agora.",
"sectionTagline": "Explora cada causa en la red.",
"sectionTagline": "Destacadas por los moderadores. Busca u ordena para explorar toda la red.",
"heroKicker": "Campañas",
"heroHeading": "Cada causa,",
"heroHeadingLine2": "en un solo lugar.",
+6 -6
View File
@@ -220,8 +220,8 @@
"myPledgesTagline": "تعهدهایی که ایجاد کرده‌ای.",
"featuredPledges": "تعهدهای ویژه",
"featuredPledgesTagline": "تعهدهایی که تیم {{appName}} برجسته کرده است.",
"allPledges": "همهٔ تعهدها",
"allPledgesTagline": "همهٔ تعهدهای موجود در شبکه را مرور کن.",
"allPledges": "تعهدها",
"allPledgesTagline": "برگزیده‌ٔ ناظران. برای مرور همهٔ تعهدها جستجو یا مرتب‌سازی کن.",
"sectionActive": "تعهدهای فعال",
"sectionUpcoming": "تعهدهای پیش‌رو",
"sectionPast": "تعهدهای گذشته",
@@ -355,8 +355,8 @@
"myGroupsTagline": "گروه‌هایی که ساخته‌ای، مدیریت می‌کنی یا دنبال می‌کنی.",
"featuredGroups": "گروه‌های ویژه",
"featuredGroupsTagline": "گروه‌های برجسته‌ای که ارزش توجه تو را دارند.",
"allGroups": "همهٔ گروه‌ها",
"allGroupsTagline": "گروه‌های {{appName}} را مرور کن یا میان همهٔ گروه‌های Nostr جستجو کن.",
"allGroups": "گروه‌ها",
"allGroupsTagline": "برگزیده‌ٔ ناظران. برای مرور همهٔ گروه‌ها جستجو یا مرتب‌سازی کن.",
"loginToSeeTitle": "برای دیدن گروه‌هایت وارد شو",
"loginToSeeBody": "گروه‌هایی که ساخته‌ای یا مدیریت می‌کنی اینجا ظاهر می‌شوند.",
"noGroupsTitle": "هنوز گروهی نیست",
@@ -720,10 +720,10 @@
"noMatchHint": "عبارت جستجوی دیگری را امتحان کنید، یا جستجو را پاک کنید."
},
"all": {
"title": "همه کمپین‌ها",
"title": "کمپین‌ها",
"seoTitle": "همه کمپین‌ها",
"description": "همه کمپین‌های منتشرشده در Agora را مرور کنید.",
"sectionTagline": "هر هدفی را در شبکه مرور کنید.",
"sectionTagline": "برگزیده‌ٔ ناظران. برای مرور کل شبکه جستجو یا مرتب‌سازی کن.",
"heroKicker": "کمپین‌ها",
"heroHeading": "هر هدف،",
"heroHeadingLine2": "در یک جا.",
+6 -6
View File
@@ -642,8 +642,8 @@
"myPledgesTagline": "Les promesses que vous avez créées.",
"featuredPledges": "Promesses en vedette",
"featuredPledgesTagline": "Promesses mises en avant par l'équipe {{appName}}.",
"allPledges": "Toutes les promesses",
"allPledgesTagline": "Parcourez toutes les promesses du réseau.",
"allPledges": "Promesses",
"allPledgesTagline": "Sélectionnées par les modérateurs. Recherchez ou triez pour parcourir toutes les promesses.",
"sectionActive": "Promesses actives",
"sectionUpcoming": "Promesses à venir",
"sectionPast": "Promesses passées",
@@ -777,8 +777,8 @@
"myGroupsTagline": "Les groupes que vous avez fondés, modérez ou suivez.",
"featuredGroups": "Groupes mis en avant",
"featuredGroupsTagline": "Des groupes remarquables qui méritent votre attention.",
"allGroups": "Tous les groupes",
"allGroupsTagline": "Parcourez les groupes {{appName}}, ou cherchez parmi tous les groupes de Nostr.",
"allGroups": "Groupes",
"allGroupsTagline": "Sélectionnés par les modérateurs. Recherchez ou triez pour parcourir tous les groupes.",
"loginToSeeTitle": "Connectez-vous pour voir vos groupes",
"loginToSeeBody": "Les groupes que vous avez fondés ou modérés apparaîtront ici.",
"noGroupsTitle": "Aucun groupe pour l'instant",
@@ -1142,10 +1142,10 @@
"noMatchHint": "Essayez un autre terme de recherche, ou effacez la recherche."
},
"all": {
"title": "Toutes les campagnes",
"title": "Campagnes",
"seoTitle": "Toutes les campagnes",
"description": "Parcourez toutes les campagnes publiées sur Agora.",
"sectionTagline": "Parcourez toutes les causes du réseau.",
"sectionTagline": "Sélectionnées par les modérateurs. Recherchez ou triez pour parcourir tout le réseau.",
"heroKicker": "Campagnes",
"heroHeading": "Chaque cause,",
"heroHeadingLine2": "au même endroit.",
+6 -6
View File
@@ -652,8 +652,8 @@
"myPledgesTagline": "आपके बनाए हुए प्लेज।",
"featuredPledges": "विशेष प्लेज",
"featuredPledgesTagline": "{{appName}} टीम द्वारा चुने गए प्लेज।",
"allPledges": "सभी प्लेज",
"allPledgesTagline": "नेटवर्क पर मौजूद हर प्लेज देखें।",
"allPledges": "प्लेज",
"allPledgesTagline": "मॉडरेटर द्वारा चयनित। हर प्लेज देखने के लिए खोजें या क्रमबद्ध करें।",
"sectionActive": "सक्रिय प्लेज",
"sectionUpcoming": "आने वाले प्लेज",
"sectionPast": "पिछले प्लेज",
@@ -787,8 +787,8 @@
"myGroupsTagline": "जिन ग्रुप को आपने बनाया, मॉडरेट किया, या फ़ॉलो किया है।",
"featuredGroups": "फ़ीचर्ड ग्रुप",
"featuredGroupsTagline": "आपके ध्यान के लायक ख़ास ग्रुप।",
"allGroups": "सभी ग्रुप",
"allGroupsTagline": "{{appName}} ग्रुप ब्राउज़ करें, या Nostr के हर ग्रुप में खोजें।",
"allGroups": "ग्रुप",
"allGroupsTagline": "मॉडरेटर द्वारा चयनित। हर ग्रुप देखने के लिए खोजें या क्रमबद्ध करें।",
"loginToSeeTitle": "अपने ग्रुप देखने के लिए लॉग इन करें",
"loginToSeeBody": "आपने जो ग्रुप बनाए या मॉडरेट किए हैं वे यहाँ दिखेंगे।",
"noGroupsTitle": "अभी कोई ग्रुप नहीं",
@@ -1152,10 +1152,10 @@
"noMatchHint": "अलग खोज शब्द आज़माएँ, या खोज साफ़ करें।"
},
"all": {
"title": "सभी कैंपेन",
"title": "कैंपेन",
"seoTitle": "सभी कैंपेन",
"description": "Agora पर पब्लिश हुए हर कैंपेन को देखें।",
"sectionTagline": "नेटवर्क पर हर मक़सद को देखें।",
"sectionTagline": "मॉडरेटर द्वारा चयनित। पूरे नेटवर्क को देखने के लिए खोजें या क्रमबद्ध करें।",
"heroKicker": "कैंपेन",
"heroHeading": "हर मक़सद,",
"heroHeadingLine2": "एक ही जगह।",
+6 -6
View File
@@ -652,8 +652,8 @@
"myPledgesTagline": "Ikrar yang Anda buat.",
"featuredPledges": "Ikrar pilihan",
"featuredPledgesTagline": "Ikrar yang disorot oleh tim {{appName}}.",
"allPledges": "Semua ikrar",
"allPledgesTagline": "Jelajahi setiap ikrar di jaringan.",
"allPledges": "Ikrar",
"allPledgesTagline": "Disorot oleh moderator. Cari atau urutkan untuk menjelajahi setiap ikrar.",
"sectionActive": "Ikrar aktif",
"sectionUpcoming": "Ikrar mendatang",
"sectionPast": "Ikrar lampau",
@@ -787,8 +787,8 @@
"myGroupsTagline": "Grup yang Anda dirikan, moderasi, atau ikuti.",
"featuredGroups": "Grup unggulan",
"featuredGroupsTagline": "Grup menonjol yang layak Anda perhatikan.",
"allGroups": "Semua grup",
"allGroupsTagline": "Jelajahi grup {{appName}}, atau cari di setiap grup di Nostr.",
"allGroups": "Grup",
"allGroupsTagline": "Disorot oleh moderator. Cari atau urutkan untuk menjelajahi setiap grup.",
"loginToSeeTitle": "Masuk untuk melihat grup Anda",
"loginToSeeBody": "Grup yang Anda dirikan atau moderasi akan muncul di sini.",
"noGroupsTitle": "Belum ada grup",
@@ -1152,10 +1152,10 @@
"noMatchHint": "Coba kata pencarian lain, atau bersihkan pencarian."
},
"all": {
"title": "Semua Kampanye",
"title": "Kampanye",
"seoTitle": "Semua kampanye",
"description": "Telusuri setiap kampanye yang dipublikasikan di Agora.",
"sectionTagline": "Jelajahi setiap aksi di jaringan.",
"sectionTagline": "Disorot oleh moderator. Cari atau urutkan untuk menjelajahi seluruh jaringan.",
"heroKicker": "Kampanye",
"heroHeading": "Setiap aksi,",
"heroHeadingLine2": "dalam satu tempat.",
+6 -6
View File
@@ -220,8 +220,8 @@
"myPledgesTagline": "ការសន្យាដែលអ្នកបានបង្កើត។",
"featuredPledges": "ការសន្យាលេចធ្លោ",
"featuredPledgesTagline": "ការសន្យាដែលត្រូវបានរំលេចដោយក្រុម {{appName}}។",
"allPledges": "ការសន្យាទាំងអស់",
"allPledgesTagline": "រកមើលការសន្យាគ្រប់ៗមួយនៅលើបណ្ដាញ។",
"allPledges": "ការសន្យា",
"allPledgesTagline": "រំលេចដោយអ្នកសម្របសម្រួល។ ស្វែងរក ឬតម្រៀបដើម្បីរកមើលការសន្យាគ្រប់ៗមួយ។",
"sectionActive": "ការសន្យាសកម្ម",
"sectionUpcoming": "ការសន្យាខាងមុខ",
"sectionPast": "ការសន្យាកន្លងមក",
@@ -355,8 +355,8 @@
"myGroupsTagline": "ក្រុមដែលអ្នកបានបង្កើត គ្រប់គ្រង ឬដាក់តាមដាន។",
"featuredGroups": "ក្រុមលេចធ្លោ",
"featuredGroupsTagline": "ក្រុមលេចធ្លោដែលសក្តិសមនឹងការយកចិត្តទុកដាក់របស់អ្នក។",
"allGroups": "ក្រុមទាំងអស់",
"allGroupsTagline": "រកមើលក្រុម {{appName}} ឬស្វែងរកក្នុងគ្រប់ក្រុមនៅលើ Nostr។",
"allGroups": "ក្រុម",
"allGroupsTagline": "រំលេចដោយអ្នកសម្របសម្រួល។ ស្វែងរក ឬតម្រៀបដើម្បីរកមើលក្រុមគ្រប់ៗមួយ។",
"loginToSeeTitle": "ចូលដើម្បីមើលក្រុមរបស់អ្នក",
"loginToSeeBody": "ក្រុមដែលអ្នកបានបង្កើត ឬគ្រប់គ្រងនឹងបង្ហាញនៅទីនេះ។",
"noGroupsTitle": "មិនទាន់មានក្រុមទេ",
@@ -720,10 +720,10 @@
"noMatchHint": "សាកល្បងពាក្យស្វែងរកផ្សេង ឬសម្អាតការស្វែងរក។"
},
"all": {
"title": "យុទ្ធនាការទាំងអស់",
"title": "យុទ្ធនាការ",
"seoTitle": "យុទ្ធនាការទាំងអស់",
"description": "រកមើលរាល់យុទ្ធនាការដែលផ្សព្វផ្សាយលើ Agora។",
"sectionTagline": "រកមើលរាល់បុព្វហេតុនៅលើបណ្ដាញ។",
"sectionTagline": "រំលេចដោយអ្នកសម្របសម្រួល។ ស្វែងរក ឬតម្រៀបដើម្បីរកមើលបណ្ដាញទាំងមូល។",
"heroKicker": "យុទ្ធនាការ",
"heroHeading": "រាល់បុព្វហេតុ",
"heroHeadingLine2": "នៅកន្លែងតែមួយ។",
+6 -6
View File
@@ -220,8 +220,8 @@
"myPledgesTagline": "هغه ژمنې چې تاسو جوړې کړې دي.",
"featuredPledges": "ځانګړې ژمنې",
"featuredPledgesTagline": "هغه ژمنې چې د {{appName}} ټیم په ګوته کړې دي.",
"allPledges": "ټولې ژمنې",
"allPledgesTagline": "په شبکه کې هره ژمنه وګورئ.",
"allPledges": "ژمنې",
"allPledgesTagline": "د څارونکو لخوا ځانګړې شوې. د ټولو ژمنو لیدلو لپاره لټون یا ترتیب وکړئ.",
"sectionActive": "فعالې ژمنې",
"sectionUpcoming": "راتلونکې ژمنې",
"sectionPast": "تېرې ژمنې",
@@ -355,8 +355,8 @@
"myGroupsTagline": "هغه ډلې چې تاسو یې جوړې کړي، اداره کوئ، یا یې تعقیبوئ.",
"featuredGroups": "ځانګړې ډلې",
"featuredGroupsTagline": "ځانګړې ډلې چې ستاسو د پاملرنې وړ دي.",
"allGroups": "ټولې ډلې",
"allGroupsTagline": {{appName}} ډلې وڅیړئ، یا د Nostr په هره ډله کې لټون وکړئ.",
"allGroups": "ډلې",
"allGroupsTagline": څارونکو لخوا ځانګړې شوې. د ټولو ډلو لیدلو لپاره لټون یا ترتیب وکړئ.",
"loginToSeeTitle": "د خپلو ډلو لیدلو لپاره ننوځئ",
"loginToSeeBody": "هغه ډلې چې تاسو یې جوړې کړي یا یې اداره کوئ به دلته راڅرګندې شي.",
"noGroupsTitle": "تر اوسه ډلې نشته",
@@ -726,10 +726,10 @@
"heroBody": "په Nostr کې خپور شوی هر د مرستو راټولولو کمپاین په یوه ځای کې راټول شوی. د بشپړې شبکې لټون وکړئ، هغه هدف ومومئ چې درته اهمیت لري، او په مستقیم ډول یې د بټکوین له لارې ملاتړ وکړئ.",
"campaignsCount_one": "په شبکه کې کمپاین",
"campaignsCount_other": "په شبکه کې کمپاینونه",
"title": "ټول کمپاینونه",
"title": "کمپاینونه",
"seoTitle": "ټول کمپاینونه",
"description": "په Agora کې خپور شوي ټول کمپاینونه وګورئ.",
"sectionTagline": "په شبکه کې هر هدف وپلټئ.",
"sectionTagline": "د څارونکو لخوا ځانګړي شوي. د ټولې شبکې لیدلو لپاره لټون یا ترتیب وکړئ.",
"searchAriaLabel": "د کمپاینونو لټون",
"searchPlaceholder": "د کمپاینونو لټون…",
"clearSearch": "د لټون پاکول",
+6 -6
View File
@@ -652,8 +652,8 @@
"myPledgesTagline": "Promessas que você criou.",
"featuredPledges": "Promessas em destaque",
"featuredPledgesTagline": "Promessas destacadas pela equipe do {{appName}}.",
"allPledges": "Todas as promessas",
"allPledgesTagline": "Explore todas as promessas da rede.",
"allPledges": "Promessas",
"allPledgesTagline": "Destacadas pelos moderadores. Pesquise ou ordene para explorar todas as promessas.",
"sectionActive": "Promessas ativas",
"sectionUpcoming": "Promessas futuras",
"sectionPast": "Promessas passadas",
@@ -787,8 +787,8 @@
"myGroupsTagline": "Grupos que você fundou, modera ou segue.",
"featuredGroups": "Grupos em destaque",
"featuredGroupsTagline": "Grupos que se destacam e merecem sua atenção.",
"allGroups": "Todos os grupos",
"allGroupsTagline": "Explore os grupos do {{appName}} ou pesquise em todos os grupos do Nostr.",
"allGroups": "Grupos",
"allGroupsTagline": "Destacados pelos moderadores. Pesquise ou ordene para explorar todos os grupos.",
"loginToSeeTitle": "Entre para ver seus grupos",
"loginToSeeBody": "Grupos que você fundou ou modera aparecerão aqui.",
"noGroupsTitle": "Nenhum grupo ainda",
@@ -1152,10 +1152,10 @@
"noMatchHint": "Tente um termo de pesquisa diferente, ou limpe a pesquisa."
},
"all": {
"title": "Todas as campanhas",
"title": "Campanhas",
"seoTitle": "Todas as campanhas",
"description": "Navegue por todas as campanhas publicadas no Agora.",
"sectionTagline": "Conheça todas as causas da rede.",
"sectionTagline": "Destacadas pelos moderadores. Pesquise ou ordene para explorar toda a rede.",
"heroKicker": "Campanhas",
"heroHeading": "Cada causa,",
"heroHeadingLine2": "em um só lugar.",
+6 -6
View File
@@ -652,8 +652,8 @@
"myPledgesTagline": "Обещания, которые вы создали.",
"featuredPledges": "Избранные обещания",
"featuredPledgesTagline": "Обещания, отмеченные командой {{appName}}.",
"allPledges": "Все обещания",
"allPledgesTagline": "Просматривайте все обещания в сети.",
"allPledges": "Обещания",
"allPledgesTagline": "Отмечено модераторами. Используйте поиск или сортировку, чтобы просмотреть все обещания.",
"sectionActive": "Активные обещания",
"sectionUpcoming": "Предстоящие обещания",
"sectionPast": "Прошлые обещания",
@@ -787,8 +787,8 @@
"myGroupsTagline": "Группы, которые вы основали, модерируете или на которые подписаны.",
"featuredGroups": "Избранные группы",
"featuredGroupsTagline": "Заметные группы, достойные вашего внимания.",
"allGroups": "Все группы",
"allGroupsTagline": "Просматривайте группы {{appName}} или ищите среди всех групп в Nostr.",
"allGroups": "Группы",
"allGroupsTagline": "Отмечено модераторами. Используйте поиск или сортировку, чтобы просмотреть все группы.",
"loginToSeeTitle": "Войдите, чтобы увидеть свои группы",
"loginToSeeBody": "Группы, которые вы основали или модерируете, появятся здесь.",
"noGroupsTitle": "Пока нет групп",
@@ -1152,10 +1152,10 @@
"noMatchHint": "Попробуйте другой поисковый запрос или очистите поиск."
},
"all": {
"title": "Все кампании",
"title": "Кампании",
"seoTitle": "Все кампании",
"description": "Просмотрите все кампании, опубликованные на Agora.",
"sectionTagline": "Просмотрите каждое дело в сети.",
"sectionTagline": "Отмечено модераторами. Используйте поиск или сортировку, чтобы просмотреть всю сеть.",
"heroKicker": "Кампании",
"heroHeading": "Каждое дело —",
"heroHeadingLine2": "в одном месте.",
+6 -6
View File
@@ -220,8 +220,8 @@
"myPledgesTagline": "Zvitsidziro zvawakagadzira.",
"featuredPledges": "Zvitsidziro zvakasarudzwa",
"featuredPledgesTagline": "Zvitsidziro zvakasimudzirwa nechikwata che{{appName}}.",
"allPledges": "Zvitsidziro zvose",
"allPledgesTagline": "Tarisa chitsidziro chega chega chiri pamutambo wenetiweki.",
"allPledges": "Zvitsidziro",
"allPledgesTagline": "Zvakatarisirwa nevatariri. Tsvaga kana kuronga kuti utarise chitsidziro chega chega.",
"sectionActive": "Zvitsidziro zviri kushanda",
"sectionUpcoming": "Zvitsidziro zviri kuuya",
"sectionPast": "Zvitsidziro zvapfuura",
@@ -355,8 +355,8 @@
"myGroupsTagline": "Mapoka awakatanga, aunotungamira, kana aunotevera.",
"featuredGroups": "Mapoka anokurumbira",
"featuredGroupsTagline": "Mapoka anobudirira anokodzera kutariswa nemi.",
"allGroups": "Mapoka ose",
"allGroupsTagline": "Tarisa mapoka e{{appName}}, kana kutsvaga mumapoka ose ari paNostr.",
"allGroups": "Mapoka",
"allGroupsTagline": "Zvakatarisirwa nevatariri. Tsvaga kana kuronga kuti utarise boka rega rega.",
"loginToSeeTitle": "Pinda kuti uone mapoka ako",
"loginToSeeBody": "Mapoka awakatanga kana aunotungamira achaonekwa pano.",
"noGroupsTitle": "Hapana mapoka parizvino",
@@ -720,10 +720,10 @@
"noMatchHint": "Edza chimwe chinotsvagwa, kana ubvise kutsvaga."
},
"all": {
"title": "Mishandirapamwe Yose",
"title": "Mishandirapamwe",
"seoTitle": "Mishandirapamwe yose",
"description": "Tarisa mishandirapamwe yose yakaiswa paAgora.",
"sectionTagline": "Tarisa chinangwa chimwe nechimwe panetwork.",
"sectionTagline": "Zvakatarisirwa nevatariri. Tsvaga kana kuronga kuti utarise netiweki yose.",
"heroKicker": "Mishandirapamwe",
"heroHeading": "Chinangwa chimwe nechimwe,",
"heroHeadingLine2": "panzvimbo imwe chete.",
+6 -6
View File
@@ -651,8 +651,8 @@
"myPledgesTagline": "Ahadi ulizounda.",
"featuredPledges": "Ahadi maalum",
"featuredPledgesTagline": "Ahadi zilizoangaziwa na timu ya {{appName}}.",
"allPledges": "Ahadi zote",
"allPledgesTagline": "Vinjari kila ahadi kwenye mtandao.",
"allPledges": "Ahadi",
"allPledgesTagline": "Zimeangaziwa na wasimamizi. Tafuta au panga ili kuvinjari kila ahadi.",
"sectionActive": "Ahadi zinazoendelea",
"sectionUpcoming": "Ahadi zijazo",
"sectionPast": "Ahadi zilizopita",
@@ -786,8 +786,8 @@
"myGroupsTagline": "Vikundi ulivyounda, unavyosimamia, au unavyofuata.",
"featuredGroups": "Vikundi maarufu",
"featuredGroupsTagline": "Vikundi vinavyojitokeza vinavyostahili usikivu wako.",
"allGroups": "Vikundi vyote",
"allGroupsTagline": "Vinjari vikundi vya {{appName}}, au tafuta kati ya kila kikundi kwenye Nostr.",
"allGroups": "Vikundi",
"allGroupsTagline": "Vimeangaziwa na wasimamizi. Tafuta au panga ili kuvinjari kila kikundi.",
"loginToSeeTitle": "Ingia ili kuona vikundi vyako",
"loginToSeeBody": "Vikundi ulivyounda au unavyosimamia vitaonekana hapa.",
"noGroupsTitle": "Hakuna vikundi bado",
@@ -1151,10 +1151,10 @@
"noMatchHint": "Jaribu neno tofauti la utafutaji, au futa utafutaji."
},
"all": {
"title": "Kampeni Zote",
"title": "Kampeni",
"seoTitle": "Kampeni zote",
"description": "Vinjari kila kampeni iliyochapishwa kwenye Agora.",
"sectionTagline": "Vinjari kila lengo kwenye mtandao.",
"sectionTagline": "Zimeangaziwa na wasimamizi. Tafuta au panga ili kuvinjari mtandao mzima.",
"searchAriaLabel": "Tafuta kampeni",
"searchPlaceholder": "Tafuta kampeni…",
"clearSearch": "Futa utafutaji",
+6 -6
View File
@@ -651,8 +651,8 @@
"myPledgesTagline": "Oluşturduğunuz taahhütler.",
"featuredPledges": "Öne çıkan taahhütler",
"featuredPledgesTagline": "{{appName}} ekibinin öne çıkardığı taahhütler.",
"allPledges": "Tüm taahhütler",
"allPledgesTagline": "Ağdaki her taahhüde göz atın.",
"allPledges": "Taahhütler",
"allPledgesTagline": "Moderatörler tarafından öne çıkarıldı. Her taahhüde göz atmak için arama yapın veya sıralayın.",
"sectionActive": "Aktif taahhütler",
"sectionUpcoming": "Yaklaşan taahhütler",
"sectionPast": "Geçmiş taahhütler",
@@ -786,8 +786,8 @@
"myGroupsTagline": "Kurduğunuz, yönettiğiniz veya takip ettiğiniz gruplar.",
"featuredGroups": "Öne çıkan gruplar",
"featuredGroupsTagline": "Dikkatinize değer öne çıkan gruplar.",
"allGroups": "Tüm gruplar",
"allGroupsTagline": "{{appName}} gruplarına göz atın veya Nostr'daki her grup arasında arama yapın.",
"allGroups": "Gruplar",
"allGroupsTagline": "Moderatörler tarafından öne çıkarıldı. Her gruba göz atmak için arama yapın veya sıralayın.",
"loginToSeeTitle": "Gruplarınızı görmek için giriş yapın",
"loginToSeeBody": "Kurduğunuz veya yönettiğiniz gruplar burada görünecek.",
"noGroupsTitle": "Henüz grup yok",
@@ -1151,10 +1151,10 @@
"noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin."
},
"all": {
"title": "Tüm Kampanyalar",
"title": "Kampanyalar",
"seoTitle": "Tüm kampanyalar",
"description": "Agora'da yayımlanmış her kampanyaya göz atın.",
"sectionTagline": "Ağdaki her davaya göz atın.",
"sectionTagline": "Moderatörler tarafından öne çıkarıldı. Ağın tamamına göz atmak için arama yapın veya sıralayın.",
"searchAriaLabel": "Kampanyaları ara",
"searchPlaceholder": "Kampanya ara…",
"clearSearch": "Aramayı temizle",
+6 -6
View File
@@ -220,8 +220,8 @@
"myPledgesTagline": "你建立的懸賞。",
"featuredPledges": "精選懸賞",
"featuredPledgesTagline": "{{appName}} 團隊重點推薦的懸賞。",
"allPledges": "全部懸賞",
"allPledgesTagline": "瀏覽網路上的每一個懸賞。",
"allPledges": "懸賞",
"allPledgesTagline": "由版主重點推薦。搜尋或排序以瀏覽所有懸賞。",
"sectionActive": "進行中的懸賞",
"sectionUpcoming": "即將開始的懸賞",
"sectionPast": "已結束的懸賞",
@@ -355,8 +355,8 @@
"myGroupsTagline": "你建立、管理或追蹤的群組。",
"featuredGroups": "精選群組",
"featuredGroupsTagline": "值得你關注的出色群組。",
"allGroups": "全部群組",
"allGroupsTagline": "瀏覽 {{appName}} 群組,或在 Nostr 上的每個群組中搜尋。",
"allGroups": "群組",
"allGroupsTagline": "由版主重點推薦。搜尋或排序以瀏覽所有群組。",
"loginToSeeTitle": "登入以檢視你的群組",
"loginToSeeBody": "你建立或管理的群組會顯示在這裡。",
"noGroupsTitle": "還沒有群組",
@@ -720,10 +720,10 @@
"noMatchHint": "請嘗試不同的搜尋字詞,或清除搜尋。"
},
"all": {
"title": "所有活動",
"title": "活動",
"seoTitle": "所有活動",
"description": "瀏覽 Agora 上釋出的所有活動。",
"sectionTagline": "瀏覽網路上的每一項事業。",
"sectionTagline": "由版主重點推薦。搜尋或排序以瀏覽整個網路。",
"heroKicker": "活動",
"heroHeading": "每一份心意,",
"heroHeadingLine2": "匯聚於此。",
+6 -6
View File
@@ -220,8 +220,8 @@
"myPledgesTagline": "你创建的悬赏。",
"featuredPledges": "精选悬赏",
"featuredPledgesTagline": "{{appName}} 团队重点推荐的悬赏。",
"allPledges": "全部悬赏",
"allPledgesTagline": "浏览网络上的每一个悬赏。",
"allPledges": "悬赏",
"allPledgesTagline": "由版主精选推荐。可搜索或排序以浏览全部悬赏。",
"sectionActive": "进行中的悬赏",
"sectionUpcoming": "即将开始的悬赏",
"sectionPast": "已结束的悬赏",
@@ -355,8 +355,8 @@
"myGroupsTagline": "你创建、管理或关注的群组。",
"featuredGroups": "精选群组",
"featuredGroupsTagline": "值得你关注的出色群组。",
"allGroups": "全部群组",
"allGroupsTagline": "浏览 {{appName}} 群组,或在 Nostr 上的每个群组中搜索。",
"allGroups": "群组",
"allGroupsTagline": "由版主精选推荐。可搜索或排序以浏览全部群组。",
"loginToSeeTitle": "登录以查看你的群组",
"loginToSeeBody": "你创建或管理的群组会显示在这里。",
"noGroupsTitle": "还没有群组",
@@ -720,10 +720,10 @@
"noMatchHint": "尝试其他搜索词,或清除搜索。"
},
"all": {
"title": "所有活动",
"title": "活动",
"seoTitle": "所有活动",
"description": "浏览 Agora 上发布的所有活动。",
"sectionTagline": "浏览网络上的每一项事业。",
"sectionTagline": "由版主精选推荐。可搜索或排序以浏览整个网络。",
"heroKicker": "活动",
"heroHeading": "每一个理念,",
"heroHeadingLine2": "汇聚于此。",
+117 -504
View File
@@ -1,211 +1,50 @@
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 { getPledgeCoord } from '@/lib/pledges';
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 { PledgeCard } from '@/components/PledgeCard';
import { Card } from '@/components/ui/card';
import {
ModerationOverlay,
ModeratorCollapsibleSection,
} from '@/components/moderation';
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
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, Sparkles, EyeOff,
} from 'lucide-react';
// ─────────────────────────────────────────────────────────────────────────────
// Skeletons / Cards
// ─────────────────────────────────────────────────────────────────────────────
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>
);
}
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,138 +52,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 [showHidden, setShowHidden] = useState(false);
const { data: rawActions, isLoading: actionsLoading } = useActions({
countryCode: selectedCountry,
limit: 300,
});
const { data: myPledges, isLoading: myPledgesLoading } = useActions({
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, isLoading: featuredPledgesLoading } = 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]);
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';
@@ -354,216 +99,62 @@ 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 [showAllFeatured, setShowAllFeatured] = useState(false);
const [showAllPledges, setShowAllPledges] = useState(false);
const allPledges = useMemo(
() => (actions ?? []).filter((action) => !featuredPledgeCoordSet.has(getPledgeCoord(action))),
[actions, featuredPledgeCoordSet],
);
const visibleMine = showAllMine ? (myPledges ?? []) : (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
const visibleFeatured = showAllFeatured ? orderedFeaturedPledges : orderedFeaturedPledges.slice(0, DEFAULT_VISIBLE);
const visibleAllPledges = showAllPledges ? allPledges : allPledges.slice(0, DEFAULT_VISIBLE);
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}
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)}
/>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12">
{user && (myPledgesLoading || (myPledges && myPledges.length > 0)) && (
{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>
</div>
{myPledgesLoading && !myPledges ? (
<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} />)}
</div>
) : (
<ActionSection
items={visibleMine}
total={myPledges?.length ?? 0}
visible={DEFAULT_VISIBLE}
showAll={showAllMine}
onToggle={() => setShowAllMine(!showAllMine)}
btcPrice={btcPrice}
/>
)}
</section>
)}
{(featuredPledgesLoading || orderedFeaturedPledges.length > 0) && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight inline-flex items-center gap-2">
<Sparkles className="size-6 text-primary" />
{t('pledges.list.featuredPledges')}
</h2>
<p className="text-sm text-muted-foreground mt-1">{t('pledges.list.featuredPledgesTagline')}</p>
</div>
{featuredPledgesLoading && orderedFeaturedPledges.length === 0 ? (
<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} />)}
</div>
) : (
<ActionSection
items={visibleFeatured}
total={orderedFeaturedPledges.length}
visible={DEFAULT_VISIBLE}
showAll={showAllFeatured}
onToggle={() => setShowAllFeatured(!showAllFeatured)}
btcPrice={btcPrice}
/>
)}
</section>
)}
<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')
: isSearching && sortMode === 'top'
? t('common.sortTop')
: isSearching && sortMode === 'new'
? t('common.sortNew')
: t('pledges.list.allPledges')}
{t('pledges.list.myPledges')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('pledges.list.allPledgesTagline')}
{t('pledges.list.myPledgesTagline')}
</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>
) : allPledges.length > 0 ? (
<ActionSection
items={visibleAllPledges}
total={allPledges.length}
items={visibleMine}
total={myPledges.length}
visible={DEFAULT_VISIBLE}
showAll={showAllPledges}
onToggle={() => setShowAllPledges(!showAllPledges)}
showAll={showAllMine}
onToggle={() => setShowAllMine(!showAllMine)}
btcPrice={btcPrice}
/>
) : (
<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>
</section>
)}
<PledgesDiscoverySection
filterPersistence="url"
showHidden={
isMod
? {
value: showHidden,
onChange: setShowHidden,
count: hiddenPledges.length,
}
: undefined
}
/>
{isMod && (
<ModeratorCollapsibleSection
@@ -575,7 +166,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) => (
<PledgeCardSkeleton key={i} />
))}
</div>
}
>
@@ -597,7 +190,10 @@ export default function ActionsPage() {
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
<ActionShareMenu
action={action}
displayTitle={action.title}
/>
</>
}
/>
@@ -638,10 +234,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();
@@ -707,7 +303,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>
@@ -733,7 +332,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')}
@@ -745,9 +348,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 (
+78 -277
View File
@@ -5,193 +5,100 @@ 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';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns';
import { useAllCampaigns, toQuerySort } 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 { parseSort } from '@/hooks/useDiscoveryFilters';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import type { Nip50Sort } from '@/hooks/useNip50Search';
import type { ParsedCampaign } from '@/lib/campaign';
/** Type-guard for the `?sort=` URL param. Default is `top` (most-zapped). */
function parseSort(value: string | null): CampaignSort {
return value === 'none' ? 'none' : 'top';
}
/**
* Map between the shared toolbar's sort vocabulary (`default` / `top` /
* `new`) and the `useAllCampaigns` hook's vocabulary (`top` / `none`).
* Lists every campaign found on relays.
*
* AllCampaignsPage doesn't have a curated/default layout — it's the
* "show me everything" page — so the toolbar's 'default' option falls
* through to 'top' here, the page's canonical ranked view. The legacy
* `none` value is preserved on the URL so existing share links keep
* working.
*/
const toToolbarSort = (s: CampaignSort): Nip50Sort => (s === 'none' ? 'new' : 'top');
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'new' ? 'none' : 'top');
/**
* Lists every campaign found on relays. Two sort modes:
* 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.
*
* - **Top** (default): ranked by total sats raised (kind 8333 donation receipts).
* - **New**: chronological by `created_at`.
*
* Both modes share a free-text search bar that 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=none&q=<search>`. 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=none&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);
const queryValue = toQuerySort(value);
if (queryValue === 'none') next.set('sort', 'none');
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({
sort,
search: debouncedSearch.trim(),
// 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: urlQuery,
countryCode: urlCountry,
limit: 200,
});
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
const { data: myCampaigns, isLoading: myCampaignsLoading } = useCampaigns({
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, isLoading: featuredLoading } = useCampaigns({
coordinates: featuredCoords,
limit: featuredCoords.length || 1,
enabled: moderationReady,
});
useSeoMeta({
title: `${t('campaigns.all.seoTitle')} | ${config.appName}`,
description: t('campaigns.all.description'),
});
const { visible, hiddenCount, hiddenCampaigns } = useMemo(() => {
const all = campaigns ?? [];
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
const featuredCoordSet = new Set(featuredCoords);
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 if (!featuredCoordSet.has(c.aTag)) {
visible.push(c);
}
}
return { visible, hiddenCount, hiddenCampaigns };
}, [campaigns, featuredCoords, 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]);
const DEFAULT_VISIBLE = 4;
const [showAllMine, setShowAllMine] = useState(false);
const [showAllFeatured, setShowAllFeatured] = useState(false);
const visibleMine = showAllMine ? (myCampaigns ?? []) : (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
const visibleFeatured = showAllFeatured ? orderedFeaturedCampaigns : orderedFeaturedCampaigns.slice(0, DEFAULT_VISIBLE);
const showSkeleton = isLoading || !moderationReady;
const activeQuery = debouncedSearch.trim();
const totalCampaigns = campaigns?.length ?? 0;
const visibleMine = showAllMine
? (myCampaigns ?? [])
: (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
return (
<main className="min-h-screen pb-16">
<AllCampaignsHero campaignCount={totalCampaigns} />
<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 && (myCampaignsLoading || (myCampaigns && myCampaigns.length > 0)) && (
{user && myCampaigns && myCampaigns.length > 0 && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
@@ -201,156 +108,48 @@ export function AllCampaignsPage() {
{t('campaigns.home.yourCampaignsDesc')}
</p>
</div>
{myCampaignsLoading && !myCampaigns ? (
<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} />)}
</div>
) : (
<CampaignSection
campaigns={visibleMine}
total={myCampaigns?.length ?? 0}
visible={DEFAULT_VISIBLE}
showAll={showAllMine}
onToggle={() => setShowAllMine(!showAllMine)}
/>
)}
<CampaignSection
campaigns={visibleMine}
total={myCampaigns.length}
visible={DEFAULT_VISIBLE}
showAll={showAllMine}
onToggle={() => setShowAllMine(!showAllMine)}
/>
</section>
)}
{(featuredLoading || orderedFeaturedCampaigns.length > 0) && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('campaigns.home.featured')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.featuredDesc', { appName: config.appName })}
</p>
</div>
{featuredLoading && orderedFeaturedCampaigns.length === 0 ? (
<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} />)}
</div>
) : (
<CampaignSection
campaigns={visibleFeatured}
total={orderedFeaturedCampaigns.length}
visible={DEFAULT_VISIBLE}
showAll={showAllFeatured}
onToggle={() => setShowAllFeatured(!showAllFeatured)}
/>
)}
</section>
)}
<CampaignsDiscoverySection
filterPersistence="url"
showHidden={
isMod
? {
value: showHidden,
onChange: setShowHidden,
count: hiddenCount,
}
: undefined
}
/>
{/* Section heading — matches the `/pledges` and `/groups` pages
so the discovery surfaces all share the same large-bold
section header pattern. Title switches between Search / Top /
New based on toolbar state; tagline stays constant.
Search input + filter button cluster on the right, paired
with the heading on the left in a single row. */}
<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={toToolbarSort(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>
{/* 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>
) : visible.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">
{visible.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
)}
{/* 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>
}
>
@@ -361,7 +160,6 @@ export function AllCampaignsPage() {
</div>
</ModeratorCollapsibleSection>
)}
</div>
</main>
);
@@ -491,7 +289,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>
+143 -437
View File
@@ -1,471 +1,177 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Trans, useTranslation } from 'react-i18next';
import { ArrowRight, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react';
import { ArrowRight, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { HeroLightningMap } from '@/components/HeroLightningMap';
import { ModeratorCollapsibleSection } from '@/components/moderation';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection';
import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection';
import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection';
import { useAppContext } from '@/hooks/useAppContext';
import type { ParsedCampaign } from '@/lib/campaign';
/** Cap on how many featured campaigns we render in the home-page row. */
const MAX_FEATURED = 4;
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* Home page (`/`).
*
* 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`.
*
* 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();
const { config } = useAppContext();
const { user } = useCurrentUser();
// Moderator pack + per-campaign label state. The label query is gated on
// moderators arriving, so during a cold load we render skeleton cards
// until both resolve. Avoids flashing the full unmoderated grid.
const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators();
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
// Featured slot list — derived from moderation labels. Sorted newest-
// featured first, capped at MAX_FEATURED, 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);
}, [moderation]);
const { data: featuredCampaigns, isLoading: featuredLoading } = useCampaigns(
moderationReady && featuredCoords.length > 0
? { coordinates: featuredCoords, limit: MAX_FEATURED }
: { coordinates: [], limit: MAX_FEATURED },
);
// 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);
}, [featuredCampaigns, featuredCoords, moderation]);
const featuredCoordSet = useMemo(() => new Set(featuredCoords), [featuredCoords]);
// The community grid is the approved-and-not-hidden set, minus featured
// (which gets its own row above). We fetch by coordinate (one filter per
// author, bundled in one REQ) to avoid pulling the entire kind-30223
// stream when only a handful are surfaced.
const approvedNotHidden = useMemo(() => {
if (!moderation) return [] as string[];
return Array.from(moderation.approvedCoords).filter((c) => !moderation.hiddenCoords.has(c));
}, [moderation]);
// Pass `coordinates: []` only once moderation is ready and the allowlist is
// empty; before that, pass `undefined` so the query is enabled but doesn't
// discriminate. We block render of the grid on `moderationReady` anyway.
const { data: approvedCampaigns, isLoading: approvedLoading } = useCampaigns(
moderationReady
? { coordinates: approvedNotHidden, limit: 60 }
: { limit: 60 },
);
// For moderators we also pull the *entire* recent kind-30223 stream so we
// can populate the Pending and Hidden sections. This second query only
// runs for mods and reuses TanStack's cache on identical keys.
const { data: allCampaignsForMods, isLoading: allLoading } = useCampaigns({
limit: 200,
});
// For non-mod creators: their own campaigns regardless of moderation state,
// so the "Your campaigns" shelf can explain why theirs aren't on the home
// page. Skip the query entirely for mods and logged-out viewers.
const { data: ownCampaigns } = useCampaigns({
authors: user && !isMod ? [user.pubkey] : undefined,
limit: 30,
});
useSeoMeta({
title: `${t('campaigns.home.seoTitle')} | ${config.appName}`,
description: t('campaigns.home.seoDescription'),
});
// Main grid excludes featured (they're shown above) and excludes any
// hidden coord just in case approvedCoords/hiddenCoords overlap (a mod can
// approve, another can hide — hide wins).
const mainGridCampaigns = useMemo(
() =>
(approvedCampaigns ?? []).filter(
(c) => !featuredCoordSet.has(c.aTag) && !moderation?.hiddenCoords.has(c.aTag),
),
[approvedCampaigns, featuredCoordSet, moderation],
);
// Pending (mod-only): campaigns that exist on the network but lack an
// approval AND aren't hidden.
const pendingCampaigns = useMemo(() => {
if (!isMod || !moderation) return [] as ParsedCampaign[];
return (allCampaignsForMods ?? []).filter(
(c) => !moderation.approvedCoords.has(c.aTag) && !moderation.hiddenCoords.has(c.aTag),
);
}, [isMod, moderation, allCampaignsForMods]);
// Hidden (mod-only): campaigns where the latest hide-axis label is `hidden`.
const hiddenCampaigns = useMemo(() => {
if (!isMod || !moderation) return [] as ParsedCampaign[];
return (allCampaignsForMods ?? []).filter((c) => moderation.hiddenCoords.has(c.aTag));
}, [isMod, moderation, allCampaignsForMods]);
// "Your campaigns" (non-mod creators only): the logged-in user's own
// campaigns that aren't yet surfaced — i.e. not approved, or hidden.
// We exclude already-approved ones so we don't double-render the same
// card in two sections; if their own campaign is in the main grid they
// already know it's live.
const yourPendingCampaigns = useMemo(() => {
if (isMod || !user || !moderation) return [] as ParsedCampaign[];
return (ownCampaigns ?? []).filter(
(c) => !moderation.approvedCoords.has(c.aTag) || moderation.hiddenCoords.has(c.aTag),
);
}, [isMod, user, moderation, ownCampaigns]);
return (
<main className="min-h-screen pb-16">
{/* Hero.
<Hero loggedIn={!!user} />
Dark, brand-driven, type-led. Three layers:
1. Near-black backdrop (`bg-[hsl(220_25%_6%)]`) — the canvas
every other element sits on. No campaign photo, no random
hue cycling: the hero looks the same on every visit, so
quality doesn't depend on which campaign is featured.
2. HeroLightningMap — decorative dark world map with curated
glowing brand-orange arcs and pulsing city nodes. Pure SVG,
negligible render cost, animations honor reduced-motion.
3. Headline column on the left, lifted by a left-edge gradient
inside HeroLightningMap so type stays readable without any
text-shadow at all. */}
<section className="relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white">
<HeroLightningMap />
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-16 lg:py-24 min-h-[440px] sm:min-h-[480px] lg:min-h-[520px] flex flex-col justify-center">
<div className="space-y-6 max-w-2xl">
<h1
className="font-display italic text-6xl sm:text-7xl lg:text-8xl font-normal tracking-wide leading-none uppercase"
style={{
// Bebas Neue only ships at weight 400. Paint a stroke the
// same color as the fill to fatten the letterforms without
// the fuzz a synthetic-bold transform would produce.
WebkitTextStroke: '0.022em currentColor',
}}
>
<Trans
i18nKey="campaigns.home.heroTagline"
components={[
// Index 0: solid brand-orange highlighter block.
// i18next injects the matched translation segment
// (the text between <0>...</0>) as this span's
// `children`, so the word renders *inside* the
// orange background.
//
// Padding: `ps-0 pe-3` keeps the first letter flush
// with the box's start edge while the box extends
// past the trailing edge as a deliberate visual
// flourish. Logical properties so RTL languages
// (ar, fa, ps) flip automatically.
//
// `text-indent: -0.06em` compensates for Bebas
// Neue's italic skew, which shifts the visible
// left edge of "U" rightward of its geometric box
// — without the nudge there's a visible gap
// between the orange block's left edge and the
// letter. The shift is small enough that other
// scripts (Arabic, Khmer, Chinese) tolerate it.
//
// NOTE: `components` MUST be an array, not an
// object keyed by `{0: ..., 1: ...}`. The object
// form silently drops the indexed tags in this
// react-i18next version, rendering the text
// without any wrapping element.
<span
key="hl"
className="inline-block w-fit ps-0 pe-3 bg-primary text-white leading-[0.95] align-baseline"
style={{ textIndent: '-0.06em' }}
/>,
// Index 1: line break. English wants the
// highlighted word on its own line as a standalone
// block. Translations that prefer inline flow
// simply omit `<1></1>` from their string.
<br key="br" />,
]}
/>
</h1>
<p className="text-base sm:text-lg text-white/80 max-w-xl">
{t('campaigns.home.heroBody')}
</p>
<div className="flex flex-wrap gap-3 pt-1">
{/* Primary CTA — solid brand-orange pill. The dark hero gives
the brand color the spotlight without competing with it. */}
<Button
size="lg"
asChild
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
>
<Link to="/campaigns/new">
<PlusCircle className="mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
<Button
variant="outline"
size="lg"
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 [&_svg]:size-[18px]"
>
<Link to="/about">
{t('campaigns.home.howItWorks')}
<ArrowRight className="ml-2 rtl:rotate-180" />
</Link>
</Button>
{!user && (
<Button
variant="outline"
size="lg"
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="#campaigns">{t('campaigns.home.exploreCampaigns')}</a>
</Button>
)}
</div>
</div>
</div>
</section>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
{/* Featured — only rendered when at least one campaign is featured
(or the featured query is still loading on first paint). */}
{(featuredCoords.length > 0 || (featuredLoading && !moderationReady)) && (
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('campaigns.home.featured')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.featuredDesc', { appName: config.appName })}
</p>
</div>
</div>
<FeaturedRow
campaigns={orderedFeatured}
isLoading={featuredLoading || !moderationReady}
expectedCount={featuredCoords.length}
/>
</section>
)}
{/* Community Campaigns — approved-and-not-hidden, minus featured.
Skeletons until the moderator pack + label state both resolve,
so we never flash an unmoderated grid. */}
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('campaigns.home.community')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.communityDesc')}
</p>
</div>
<Button asChild variant="outline" className="hidden sm:inline-flex">
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
</div>
{moderatorsLoading || !moderationReady || approvedLoading ? (
<CampaignGridSkeleton />
) : mainGridCampaigns.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{mainGridCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
)}
{/* "Browse all campaigns" link — reveals the page that includes
campaigns not yet moderated (and, optionally, hidden ones). */}
<div className="pt-2 text-center sm:text-left">
<Button asChild variant="ghost" size="sm">
<Link to="/campaigns/all">{t('campaigns.home.browseAll')}</Link>
</Button>
</div>
</section>
{/* Moderator-only: campaigns awaiting an approval decision. */}
{isMod && (
<ModeratorCollapsibleSection
icon={<Hourglass className="size-4" />}
title={t('campaigns.home.pending')}
description={t('campaigns.home.pendingDesc')}
count={pendingCampaigns.length}
isLoading={allLoading}
emptyText={t('campaigns.home.pendingEmpty')}
skeleton={<CampaignGridSkeleton />}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{pendingCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
</ModeratorCollapsibleSection>
)}
{/* Moderator-only: campaigns currently hidden. */}
{isMod && (
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
title={t('campaigns.home.hidden')}
description={t('campaigns.home.hiddenDesc')}
count={hiddenCampaigns.length}
isLoading={allLoading}
emptyText={t('campaigns.home.hiddenEmpty')}
skeleton={<CampaignGridSkeleton />}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{hiddenCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
</ModeratorCollapsibleSection>
)}
{/* Non-mod creator: surface their own not-yet-approved campaigns
so they understand the campaign is live on the network but
isn't on the homepage yet. */}
{!isMod && user && yourPendingCampaigns.length > 0 && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight inline-flex items-center gap-2">
<ShieldCheck className="size-6 text-primary" />
{t('campaigns.home.yourCampaigns')}
</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
{t('campaigns.home.yourCampaignsDesc')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{yourPendingCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
</section>
)}
<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>
);
}
/**
* 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';
}
// ═══════════════════════════════════════════════════════════════════════════════
// Hero
// ═══════════════════════════════════════════════════════════════════════════════
/** Renders the featured row with an adaptive column count. */
function FeaturedRow({
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, 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>
);
}
function CampaignGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
);
}
function EmptyState() {
function Hero({ loggedIn }: { loggedIn: boolean }) {
const { t } = useTranslation();
const { config } = useAppContext();
return (
<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">
<h3 className="text-lg font-semibold">{t('campaigns.home.empty')}</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.home.emptyHint', { appName: config.appName })}
/* Hero.
Dark, brand-driven, type-led. Three layers:
1. Near-black backdrop (`bg-[hsl(220_25%_6%)]`) — the canvas
every other element sits on. No campaign photo, no random
hue cycling: the hero looks the same on every visit, so
quality doesn't depend on which campaign is featured.
2. HeroLightningMap — decorative dark world map with curated
glowing brand-orange arcs and pulsing city nodes. Pure SVG,
negligible render cost, animations honor reduced-motion.
3. Headline column on the left, lifted by a left-edge gradient
inside HeroLightningMap so type stays readable without any
text-shadow at all. */
<section className="relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white">
<HeroLightningMap />
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-16 lg:py-24 min-h-[440px] sm:min-h-[480px] lg:min-h-[520px] flex flex-col justify-center">
<div className="space-y-6 max-w-2xl">
<h1
className="font-display italic text-6xl sm:text-7xl lg:text-8xl font-normal tracking-wide leading-none uppercase"
style={{
// Bebas Neue only ships at weight 400. Paint a stroke the
// same color as the fill to fatten the letterforms without
// the fuzz a synthetic-bold transform would produce.
WebkitTextStroke: '0.022em currentColor',
}}
>
<Trans
i18nKey="campaigns.home.heroTagline"
components={[
// Index 0: solid brand-orange highlighter block.
// i18next injects the matched translation segment
// (the text between <0>...</0>) as this span's
// `children`, so the word renders *inside* the
// orange background.
//
// Padding: `ps-0 pe-3` keeps the first letter flush
// with the box's start edge while the box extends
// past the trailing edge as a deliberate visual
// flourish. Logical properties so RTL languages
// (ar, fa, ps) flip automatically.
//
// `text-indent: -0.06em` compensates for Bebas
// Neue's italic skew, which shifts the visible
// left edge of "U" rightward of its geometric box
// — without the nudge there's a visible gap
// between the orange block's left edge and the
// letter. The shift is small enough that other
// scripts (Arabic, Khmer, Chinese) tolerate it.
//
// NOTE: `components` MUST be an array, not an
// object keyed by `{0: ..., 1: ...}`. The object
// form silently drops the indexed tags in this
// react-i18next version, rendering the text
// without any wrapping element.
<span
key="hl"
className="inline-block w-fit ps-0 pe-3 bg-primary text-white leading-[0.95] align-baseline"
style={{ textIndent: '-0.06em' }}
/>,
// Index 1: line break. English wants the
// highlighted word on its own line as a standalone
// block. Translations that prefer inline flow
// simply omit `<1></1>` from their string.
<br key="br" />,
]}
/>
</h1>
<p className="text-base sm:text-lg text-white/80 max-w-xl">
{t('campaigns.home.heroBody')}
</p>
<div className="flex flex-wrap gap-3 pt-1">
{/* Primary CTA — solid brand-orange pill. The dark hero gives
the brand color the spotlight without competing with it. */}
<Button
size="lg"
asChild
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
>
<Link to="/campaigns/new">
<PlusCircle className="mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
<Button
variant="outline"
size="lg"
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 [&_svg]:size-[18px]"
>
<Link to="/about">
{t('campaigns.home.howItWorks')}
<ArrowRight className="ml-2 rtl:rotate-180" />
</Link>
</Button>
{!loggedIn && (
<Button
variant="outline"
size="lg"
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="#discover">{t('campaigns.home.exploreCampaigns')}</a>
</Button>
)}
</div>
</div>
<Button asChild>
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
</CardContent>
</Card>
</div>
</section>
);
}
+91 -301
View File
@@ -7,32 +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 { hasAgoraTag } from '@/lib/agoraNoteTags';
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();
@@ -63,198 +72,46 @@ export function CommunitiesPage() {
navigate('/groups/new');
};
// On-page NIP-50 search + sort + show-hidden toolbar state.
//
// Default sort, empty query → curated "My groups" / "Featured" /
// moderator shelves below.
// 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];
},
// 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,
});
// Lift org moderation to the page so search results can drop hidden
// groups (or include them when the Show-hidden switch is on). The
// Hidden ModeratorCollapsibleSection below derives its data from the
// same `allOrgs` fetch, so no additional query round-trip is needed.
const { data: orgModeration } = useOrganizationModeration();
const { searchHits, searchHiddenCount } = useMemo(() => {
if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
const { hiddenGroups, hiddenCount } = useMemo(() => {
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]);
const { data: allOrgs, isLoading: allOrgsLoading } = useDiscoverCommunities({ limit: 200 });
const { allGroups, allHiddenCount, hiddenGroups } = useMemo(() => {
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
const featuredCoords = orgModeration?.featuredCoords ?? new Set<string>();
let hidden = 0;
const visible: ParsedCommunity[] = [];
const hiddenList: ParsedCommunity[] = [];
const list: ParsedCommunity[] = [];
for (const org of allOrgs ?? []) {
if (hiddenCoords.has(org.aTag)) {
hidden += 1;
hiddenList.push(org);
if (isMod && showHidden) visible.push(org);
} else if (hasAgoraTag(org.tags) && !featuredCoords.has(org.aTag)) {
visible.push(org);
}
if (hiddenCoords.has(org.aTag)) list.push(org);
}
return { allGroups: visible, allHiddenCount: hidden, hiddenGroups: hiddenList };
}, [allOrgs, isMod, orgModeration, showHidden]);
// Search + sort + show-hidden cluster for the All section.
const searchToolbar = (
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={sortMode}
onSortChange={setSortMode}
searchPlaceholderKey="groups.list.searchPlaceholder"
searchAriaLabelKey="groups.list.searchAriaLabel"
showHidden={isMod ? {
value: showHidden,
onChange: setShowHidden,
count: isSearching ? searchHiddenCount : allHiddenCount,
} : undefined}
/>
);
return { hiddenGroups: list, hiddenCount: list.length };
}, [allOrgs, orgModeration]);
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
}
/>
<FeaturedOrganizationsShelf />
<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')
: isSearching && sortMode === 'top'
? t('common.sortTop')
: isSearching && sortMode === 'new'
? t('common.sortNew')
: 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>
)}
</>
) : allOrgsLoading ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : allGroups.length > 0 ? (
<CommunityGrid>
{allGroups.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" />}
@@ -460,7 +317,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
}
// ═══════════════════════════════════════════════════════════════════════════════
// Community shelves
// "My groups" shelf
// ═══════════════════════════════════════════════════════════════════════════════
type UserOrganizationsResult = ReturnType<typeof useUserOrganizations>;
@@ -472,8 +329,23 @@ 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.
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.
if (!organizations || organizations.length === 0) return null;
const COLLAPSED_COUNT = 4;
const visible = expanded ? organizations : organizations.slice(0, COLLAPSED_COUNT);
const canExpand = organizations.length > COLLAPSED_COUNT;
return (
<section className="space-y-5">
@@ -485,122 +357,40 @@ function MyCommunitiesShelf({
{t('groups.list.myGroupsTagline')}
</p>
</div>
<MyCommunitiesShelfContent userOrganizations={userOrganizations} />
</section>
);
}
function MyCommunitiesShelfContent({
userOrganizations,
}: {
userOrganizations: UserOrganizationsResult;
}) {
const { t } = useTranslation();
// "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.
const { data: organizations, isLoading } = userOrganizations;
const [expanded, setExpanded] = useState(false);
if (isLoading) {
return (
<CommunityGrid>
{Array.from({ length: 4 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
);
}
if (!organizations || organizations.length === 0) return null;
const COLLAPSED_COUNT = 4;
const visible = expanded ? organizations : organizations.slice(0, COLLAPSED_COUNT);
const canExpand = organizations.length > COLLAPSED_COUNT;
return (
<div className="space-y-4">
<CommunityGrid>
{visible.map((entry) => (
<CommunityMiniCard
key={entry.community.aTag}
community={entry.community}
className="w-full"
/>
))}
</CommunityGrid>
{canExpand && (
<div className="flex justify-center">
<Button
type="button"
variant="ghost"
onClick={() => setExpanded((v) => !v)}
className="rounded-full text-sm"
aria-expanded={expanded}
>
{expanded ? (
<>
<ChevronUp className="size-4 mr-1.5" />
{t('groups.list.showLess')}
</>
) : (
<>
<ChevronDown className="size-4 mr-1.5" />
{t('groups.list.showMore', { count: organizations.length - COLLAPSED_COUNT })}
</>
)}
</Button>
</div>
)}
</div>
);
}
function FeaturedOrganizationsShelf() {
const { data: featured, isLoading, isPending } = useFeaturedOrganizations();
const hasFeatured = !!featured && featured.length > 0;
if ((isPending || isLoading) && !hasFeatured) {
return (
<section className="space-y-5">
<FeaturedOrganizationsHeading />
<div className="space-y-4">
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
{visible.map((entry) => (
<CommunityMiniCard
key={entry.community.aTag}
community={entry.community}
className="w-full"
/>
))}
</CommunityGrid>
</section>
);
}
if (!hasFeatured) return null;
return (
<section className="space-y-5">
<FeaturedOrganizationsHeading />
<CommunityGrid>
{featured.map((entry) => (
<CommunityMiniCard
key={entry.community.aTag}
community={entry.community}
className="w-full"
/>
))}
</CommunityGrid>
{canExpand && (
<div className="flex justify-center">
<Button
type="button"
variant="ghost"
onClick={() => setExpanded((v) => !v)}
className="rounded-full text-sm"
aria-expanded={expanded}
>
{expanded ? (
<>
<ChevronUp className="size-4 mr-1.5" />
{t('groups.list.showLess')}
</>
) : (
<>
<ChevronDown className="size-4 mr-1.5" />
{t('groups.list.showMore', { count: organizations.length - COLLAPSED_COUNT })}
</>
)}
</Button>
</div>
)}
</div>
</section>
);
}
function FeaturedOrganizationsHeading() {
const { t } = useTranslation();
return (
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('groups.list.featuredGroups')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('groups.list.featuredGroupsTagline')}
</p>
</div>
);
}