diff --git a/src/components/ActionShareMenu.tsx b/src/components/ActionShareMenu.tsx index d9f7dd6c..8c9a8e4c 100644 --- a/src/components/ActionShareMenu.tsx +++ b/src/components/ActionShareMenu.tsx @@ -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 diff --git a/src/components/PledgeCard.tsx b/src/components/PledgeCard.tsx index fa493b5c..9f040160 100644 --- a/src/components/PledgeCard.tsx +++ b/src/components/PledgeCard.tsx @@ -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({ ); } + +/** + * 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 `` / 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 ( + + +
+ + + + + +
+
+ ); +} diff --git a/src/components/discovery/CampaignsDiscoverySection.tsx b/src/components/discovery/CampaignsDiscoverySection.tsx index 0c941d60..5f298796 100644 --- a/src/components/discovery/CampaignsDiscoverySection.tsx +++ b/src/components/discovery/CampaignsDiscoverySection.tsx @@ -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: diff --git a/src/components/discovery/PledgesDiscoverySection.tsx b/src/components/discovery/PledgesDiscoverySection.tsx index f9b29b80..b1ac54d2 100644 --- a/src/components/discovery/PledgesDiscoverySection.tsx +++ b/src/components/discovery/PledgesDiscoverySection.tsx @@ -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 ( - - -
- - - - - -
-
- ); -} +import { getPledgeCoord } from '@/lib/pledges'; interface PledgesDiscoverySectionProps { /** @@ -285,7 +266,7 @@ export function PledgesDiscoverySection({ {isSearchLoading && !searchHits ? (
{Array.from({ length: 8 }).map((_, i) => ( - + ))}
) : searchHits && searchHits.length > 0 ? ( @@ -316,7 +297,7 @@ export function PledgesDiscoverySection({ ) : isLoading ? (
{Array.from({ length: 8 }).map((_, i) => ( - + ))}
) : idlePledges.length > 0 ? ( diff --git a/src/hooks/useAllCampaigns.ts b/src/hooks/useAllCampaigns.ts index 344e8128..8defa02b 100644 --- a/src/hooks/useAllCampaigns.ts +++ b/src/hooks/useAllCampaigns.ts @@ -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; diff --git a/src/hooks/useDiscoveryFilters.ts b/src/hooks/useDiscoveryFilters.ts index c1b81d33..4e8f84b3 100644 --- a/src/hooks/useDiscoveryFilters.ts +++ b/src/hooks/useDiscoveryFilters.ts @@ -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'; diff --git a/src/lib/pledges.ts b/src/lib/pledges.ts index 82112511..d0ba082e 100644 --- a/src/lib/pledges.ts +++ b/src/lib/pledges.ts @@ -1,5 +1,18 @@ import { formatSats, satsToUSDWhole } from '@/lib/bitcoin'; +/** + * Addressable coordinate for a pledge (kind 36639): `36639::`. + * + * 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`; diff --git a/src/pages/ActionsPage.tsx b/src/pages/ActionsPage.tsx index ff32b096..cce7fb08 100644 --- a/src/pages/ActionsPage.tsx +++ b/src/pages/ActionsPage.tsx @@ -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 ( - - -
- - - - - -
-
- ); -} /** * Dedicated `/pledges` page. @@ -187,7 +167,7 @@ export default function ActionsPage() { skeleton={
{Array.from({ length: 4 }).map((_, i) => ( - + ))}
} diff --git a/src/pages/AllCampaignsPage.tsx b/src/pages/AllCampaignsPage.tsx index 019b8b63..9d9479d9 100644 --- a/src/pages/AllCampaignsPage.tsx +++ b/src/pages/AllCampaignsPage.tsx @@ -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. *