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.
This commit is contained in:
lemon
2026-05-28 14:42:14 -07:00
parent 3525f685bb
commit 3e433e70cb
9 changed files with 74 additions and 77 deletions
+1 -4
View File
@@ -24,12 +24,9 @@ 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';
function getPledgeCoord(action: Action) {
return `36639:${action.pubkey}:${action.id}`;
}
/**
* Per-card kebab menu for pledges. Surfaces:
* • Delete (owner only) — NIP-09 with both `e` and `a` tags so
+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>
);
}
@@ -7,23 +7,14 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns';
import { 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 { Nip50Sort } from '@/hooks/useNip50Search';
import type { ParsedCampaign } from '@/lib/campaign';
/**
* Map the toolbar's sort vocabulary (`default` / `top` / `new`) to the
* `useAllCampaigns` hook's vocabulary (`top` / `none`). `'new'` and
* `'default'` both map to `'none'` (chronological) — the section
* handles the "show featured only" framing on top of that.
*/
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none');
interface CampaignsDiscoverySectionProps {
/**
* Where this section's filter state lives:
@@ -3,10 +3,9 @@ import { useTranslation } from 'react-i18next';
import { ActionShareMenu } from '@/components/ActionShareMenu';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { ModerationOverlay } from '@/components/moderation';
import { PledgeCard } from '@/components/PledgeCard';
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
import { parseAction, useActions, type Action } from '@/hooks/useActions';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
@@ -14,25 +13,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
import { useNip50Search } from '@/hooks/useNip50Search';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
function getPledgeCoord(action: Action) {
return `36639:${action.pubkey}:${action.id}`;
}
function ActionSkeleton() {
return (
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="flex-1 p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
);
}
import { getPledgeCoord } from '@/lib/pledges';
interface PledgesDiscoverySectionProps {
/**
@@ -285,7 +266,7 @@ export function PledgesDiscoverySection({
{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} />
<PledgeCardSkeleton key={i} />
))}
</div>
) : searchHits && searchHits.length > 0 ? (
@@ -316,7 +297,7 @@ export function PledgesDiscoverySection({
) : 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} />
<PledgeCardSkeleton key={i} />
))}
</div>
) : idlePledges.length > 0 ? (
+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;
+7 -1
View File
@@ -11,8 +11,14 @@ import type { Nip50Sort } from '@/hooks/useNip50Search';
* - `'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.
*/
function parseSort(value: string | null): Nip50Sort {
export function parseSort(value: string | null): Nip50Sort {
if (value === 'top') return 'top';
if (value === 'new') return 'new';
return 'default';
+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`;
+3 -23
View File
@@ -21,6 +21,7 @@ import { usePledgeModeration } from '@/hooks/usePledgeModeration';
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 { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
@@ -28,29 +29,8 @@ import {
ModerationOverlay,
ModeratorCollapsibleSection,
} from '@/components/moderation';
import { PledgeCard } from '@/components/PledgeCard';
import { Card } from '@/components/ui/card';
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
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>
);
}
/**
* Dedicated `/pledges` page.
@@ -187,7 +167,7 @@ export default function ActionsPage() {
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} />
<PledgeCardSkeleton key={i} />
))}
</div>
}
+2 -16
View File
@@ -11,30 +11,16 @@ 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 { 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 mirroring the one in `useDiscoveryFilters`. Pulled out
* to keep the page's hidden-section derivation self-contained, since
* it needs to read the same URL params the section reads.
*/
function parseSort(value: string | null): Nip50Sort {
if (value === 'top') return 'top';
if (value === 'new') return 'new';
return 'default';
}
/** Toolbar sort vocabulary → useAllCampaigns vocabulary. */
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none');
/**
* Lists every campaign found on relays.
*