diff --git a/NIP.md b/NIP.md index 6728b791..707b9678 100644 --- a/NIP.md +++ b/NIP.md @@ -14,7 +14,7 @@ |-------|----------------------------|----------------------------------------------------------------| | 30223 | Campaign | Fundraising campaign with a list of on-chain Bitcoin recipients | | 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards | -| 36639 | Activist Action | Country-scoped activist challenge with a sats bounty | +| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats | ### Agora Protocols @@ -532,19 +532,19 @@ After resolution (assuming `$follows` = `["pk1", "pk2"]`): --- -## Kind 36639: Activist Action +## Kind 36639: Pledge ### Summary -Addressable event kind for publishing **activist actions**. An action is a task — take a photo, make art, gather information, or take direct action — with an optional country scope, optional community scope, and an optional sats bounty paid out via NIP-57 zaps to the best **submissions**. +Addressable event kind for publishing **pledges**. A pledge is donor intent to fund a concrete action, evidence request, or outcome — take a photo, make art, gather information, clean a beach, or take direct action — with an optional country scope, optional community scope, and a sats-denominated pledge amount paid out via zaps or donation receipts to the best **submissions**. -Submissions are **NIP-22 comments** (kind 1111) authored under the action's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse. +Submissions are **NIP-22 comments** (kind 1111) authored under the pledge's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse. ### Trust model -Actions are user-generated. Anyone can publish a kind 36639 event, and Agora displays valid actions without platform-admin or country-organizer author filtering. +Pledges are user-generated. Anyone can publish a kind 36639 event, and Agora displays valid pledges without platform-admin or country-organizer author filtering. -Community-scoped actions inherit the community's moderation context. Clients rendering a specific community SHOULD query by the community `A` tag and apply that community's moderation and membership filters. +Community-scoped pledges inherit the community's moderation context. Clients rendering a specific community SHOULD query by the community `A` tag and apply that community's moderation and membership filters. ### Event Structure @@ -565,7 +565,7 @@ Community-scoped actions inherit the community's moderation context. Clients ren ["image", "https://example.com/cover.jpg"], ["start", "1729000000"], ["deadline", "1729604800"], - ["alt", "Agora activist action: Plant a tree in your neighborhood"] + ["alt", "Agora pledge: Plant a tree in your neighborhood"] ] } ``` @@ -577,32 +577,32 @@ Community-scoped actions inherit the community's moderation context. Clients ren | `d` | Yes | Unique identifier (typically slug + timestamp). Forms the addressable coordinate `36639::`. | | `title` | Yes | Short title shown on cards. | | `challenge-type` | Yes | One of `photo`, `art`, `info`, `action`. Drives the display icon and submission expectations. | -| `bounty` | Yes | Bounty in **sats**, as an unsigned integer string. Paid out via zaps to the chosen submission(s). | +| `bounty` | Yes | Pledge amount in **sats**, as an unsigned integer string. Paid out via zaps or donation receipts to chosen submission(s). | | `i` | No | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. | -| `A` | No | Community root coordinate for community-scoped actions, e.g. `34550::`. | -| `K` | No | Root kind hint for community-scoped actions. Use `34550` when `A` points to a NIP-72 community. | +| `A` | No | Community root coordinate for community-scoped pledges, e.g. `34550::`. | +| `K` | No | Root kind hint for community-scoped pledges. Use `34550` when `A` points to a NIP-72 community. | | `P` | No | Root author hint for community-scoped actions. Use the community definition author pubkey. | | `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. | | `image` | No | Cover image URL. | -| `start` | No | Unix timestamp when the action becomes active. Defaults to `created_at`. | -| `deadline` | No | Unix timestamp when the action expires. Defaults to `start + 48h`. | -| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora activist action: "`. | +| `start` | No | Unix timestamp when the pledge becomes active. Defaults to `created_at`. | +| `deadline` | No | Optional Unix timestamp when the pledge expires. Omit for open-ended pledges. | +| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora pledge: <title>"`. | ### Content -Long-form description of the action. Plain text or light markdown. Clients render this as the action's body on the detail page. +Long-form description of the pledge. Plain text or light markdown. Clients render this as the pledge's body on the detail page. ### Submissions -Submissions are kind 1111 NIP-22 comments addressed to the action's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD: +Submissions are kind 1111 NIP-22 comments addressed to the pledge's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD: -- Sort top-level submissions by **total zap amount** (sum of NIP-57 zap receipts on each submission), descending. -- Show the bounty as the prize pool that organizers can distribute to top submissions via zaps. -- Hide submissions with `created_at` after the action's `deadline` for "past" leaderboards (or surface them separately as "late submissions"). +- Sort top-level submissions by **total funded amount** (sum of kind 9735 zap receipts and kind 8333 donation receipts on each submission), descending. +- Show the pledge amount, total funded, and remaining amount as a trust-based progress indicator. There is no escrow guarantee. +- Hide submissions with `created_at` after the pledge's `deadline` for "past" leaderboards (or surface them separately as "late submissions"). Open-ended pledges have no deadline cutoff. ### Discovery -Clients querying actions globally: +Clients querying pledges globally: ```json { "kinds": [36639], "#t": ["agora-action", "pathos-challenge", "agora-challenge"], "limit": 50 } diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 51ff8ea8..b40f0181 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -310,6 +310,8 @@ export function AppRouter() { <Route path="/i/*" element={<ExternalContentPage />} /> <Route path="/actions" element={<ActionsPage />} /> <Route path="/actions/new" element={<CreateActionPage />} /> + <Route path="/pledges" element={<ActionsPage />} /> + <Route path="/pledges/new" element={<CreateActionPage />} /> <Route path="/agent" element={<AIChatPage />} /> <Route path="/organizers" element={<OrganizersPage />} /> <Route path="/dashboard" element={<EventDashboardPage />} /> diff --git a/src/components/ActionContent.tsx b/src/components/ActionContent.tsx index 1b466705..7731610c 100644 --- a/src/components/ActionContent.tsx +++ b/src/components/ActionContent.tsx @@ -2,12 +2,14 @@ import { Link } from 'react-router-dom'; import { format } from 'date-fns'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; -import { Bitcoin, Camera, Clock, Info, Megaphone, Palette } from 'lucide-react'; +import { Camera, Clock, DollarSign, Info, Megaphone, Palette } from 'lucide-react'; import { parseAction, type Action } from '@/hooks/useActions'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; import { countryCodeToFlag, getGeoDisplayName } from '@/lib/countries'; import { CountryFlag } from '@/components/CountryFlag'; import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; +import { formatSats, satsToUSDWhole } from '@/lib/bitcoin'; import { cn } from '@/lib/utils'; const ACTION_ICONS = { @@ -26,6 +28,7 @@ function actionNaddr(action: Action): string { } export function ActionContent({ event, compact = true }: { event: NostrEvent; compact?: boolean }) { + const { data: btcPrice } = useBtcPrice(); const action = parseAction(event); if (!action) return null; @@ -65,7 +68,7 @@ export function ActionContent({ event, compact = true }: { event: NostrEvent; co <div className="absolute bottom-3 left-3 right-3 flex items-center justify-between gap-2 text-white"> <span className="inline-flex items-center gap-1.5 rounded-full bg-black/45 px-2.5 py-1 text-xs font-semibold backdrop-blur-sm"> <Icon className="size-3.5" /> - Action + Pledge </span> {isExpired ? ( <span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm"> @@ -93,9 +96,11 @@ export function ActionContent({ event, compact = true }: { event: NostrEvent; co </p> )} <div className="flex items-center gap-2 text-sm"> - <Bitcoin className="size-4 shrink-0 text-primary" /> - <span className="font-semibold">{action.bounty.toLocaleString()}</span> - <span className="text-xs text-muted-foreground">sats</span> + <DollarSign className="size-4 shrink-0 text-primary" /> + <span className="font-semibold"> + {btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`} + </span> + {btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>} {action.countryCode && ( <> <span className="text-muted-foreground/50">·</span> diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index b99c63d2..48f17a54 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -149,7 +149,7 @@ const KIND_LABELS: Record<number, string> = { 34550: 'a community', 9041: 'a goal', 35128: 'an nsite', - 36639: 'an action', + 36639: 'a pledge', 36787: 'a track', 37381: 'a Magic deck', 37516: 'a treasure', diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index c8f6b1dd..ae4ff5fd 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -533,7 +533,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { }, { id: 'new-action', - label: 'New action', + label: 'New pledge', icon: <Megaphone className="size-4" />, onSelect: () => { setActiveTab('activity'); @@ -788,7 +788,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { </TabsContent> {/* ── Activity tab — chronological stream of initiatives - (actions + goals + events) interleaved with threaded NIP-22 discussion, + (pledges + goals + events) interleaved with threaded NIP-22 discussion, followed by past initiatives. ── */} <TabsContent value="activity" className="mt-0"> <ComposeBox compact replyTo={event} /> @@ -808,7 +808,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { (communityEvents ?? []).length > 0 ) ? 'No activity from community members yet. Toggle the shield icon to see everything.' - : <>No activity yet.{user ? ' Start a discussion, create an action, set a goal, or schedule an event!' : ''}</>} + : <>No activity yet.{user ? ' Start a discussion, create a pledge, set a goal, or schedule an event!' : ''}</>} </div> ) : ( <FeedCard className="mx-0 sm:mx-0 mt-2"> diff --git a/src/components/CreateActionDialog.tsx b/src/components/CreateActionDialog.tsx index f9865245..a2550684 100644 --- a/src/components/CreateActionDialog.tsx +++ b/src/components/CreateActionDialog.tsx @@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useIsMobile } from '@/hooks/useIsMobile'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; @@ -21,6 +22,7 @@ import type { Action } from '@/hooks/useActions'; import { createCountryIdentifier } from '@/lib/countryIdentifiers'; import { countryCodeToFlag, getAllCountries, getGeoDisplayName } from '@/lib/countries'; import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; +import { formatSats, satsToUSDWhole, usdToSats } from '@/lib/bitcoin'; import { cn } from '@/lib/utils'; interface CreateActionDialogProps { @@ -34,7 +36,7 @@ interface CreateActionFormState { title: string; description: string; type: Action['type']; - bounty: string; + pledgeUsd: string; startDate: string; startTime: string; deadline: string; @@ -87,6 +89,7 @@ function CreateActionForm({ pageCountryCode?: string; }) { const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const { data: btcPrice } = useBtcPrice(); const allCountries = useMemo(() => getAllCountries(), []); const [countryPickerOpen, setCountryPickerOpen] = useState(false); const [selectedDefaultId, setSelectedDefaultId] = useState<string | null>(() => { @@ -217,7 +220,7 @@ function CreateActionForm({ <Label htmlFor="description">Description</Label> <Textarea id="description" - placeholder="Explain what submissions should look like, why this matters, and how the bounty will be paid out..." + placeholder="Explain the action, evidence, or outcome you want to inspire and what submissions should include..." className="min-h-[80px]" value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} @@ -233,13 +236,18 @@ function CreateActionForm({ <SelectItem value="photo"><div className="flex items-center gap-2"><Camera className="h-4 w-4" /> Photo</div></SelectItem> <SelectItem value="art"><div className="flex items-center gap-2"><Palette className="h-4 w-4" /> Art</div></SelectItem> <SelectItem value="info"><div className="flex items-center gap-2"><Info className="h-4 w-4" /> Info</div></SelectItem> - <SelectItem value="action"><div className="flex items-center gap-2"><Megaphone className="h-4 w-4" /> Action</div></SelectItem> + <SelectItem value="action"><div className="flex items-center gap-2"><Megaphone className="h-4 w-4" /> Direct action</div></SelectItem> </SelectContent> </Select> </div> <div className="space-y-2"> - <Label htmlFor="bounty">Bounty (sats)</Label> - <Input id="bounty" type="number" placeholder="10000" value={formData.bounty} onChange={(e) => setFormData({ ...formData, bounty: e.target.value })} /> + <Label htmlFor="pledgeUsd">Pledge amount (USD)</Label> + <Input id="pledgeUsd" type="number" min={1} step="0.01" placeholder="100" value={formData.pledgeUsd} onChange={(e) => setFormData({ ...formData, pledgeUsd: e.target.value })} /> + <p className="text-xs text-muted-foreground"> + {usdToSats(Number(formData.pledgeUsd), btcPrice) > 0 && btcPrice + ? `${formatSats(usdToSats(Number(formData.pledgeUsd), btcPrice))} sats will be stored (${satsToUSDWhole(usdToSats(Number(formData.pledgeUsd), btcPrice), btcPrice)} at the current rate).` + : 'Agora stores the pledge in sats on Nostr.'} + </p> </div> </div> @@ -258,7 +266,7 @@ function CreateActionForm({ <Input id="deadline" type="date" className="w-full min-w-0" value={formData.deadline} onChange={(e) => setFormData({ ...formData, deadline: e.target.value })} /> {formData.deadline && <Input id="time" type="time" className="w-full min-w-0" value={formData.time} onChange={(e) => setFormData({ ...formData, time: e.target.value })} />} <p className="text-xs text-muted-foreground"> - {!formData.deadline && 'Defaults to 48 hours after start'} + {!formData.deadline && 'Open-ended. Add a deadline if urgency matters.'} {formData.deadline && !formData.time && ' • Ends at 23:59 local time'} </p> </div> @@ -272,9 +280,9 @@ function CreateActionForm({ )} </div> <div className="flex flex-col gap-2 p-4 pt-2"> - <Button onClick={handleSubmit} disabled={!formData.title || !formData.description || !formData.bounty || isSubmitting} className="gap-2 w-full"> + <Button onClick={handleSubmit} disabled={!formData.title || !formData.description || !formData.pledgeUsd || usdToSats(Number(formData.pledgeUsd), btcPrice) <= 0 || isSubmitting} className="gap-2 w-full"> {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />} - Create action + Create pledge </Button> <Button variant="outline" onClick={onCancel} className="w-full">Cancel</Button> </div> @@ -286,6 +294,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha const [isSubmitting, setIsSubmitting] = useState(false); const { user } = useCurrentUser(); const { mutateAsync: createEvent } = useNostrPublish(); + const { data: btcPrice } = useBtcPrice(); const queryClient = useQueryClient(); const isMobile = useIsMobile(); const { toast } = useToast(); @@ -295,7 +304,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha title: '', description: '', type: 'photo', - bounty: '', + pledgeUsd: '', startDate: '', startTime: '', deadline: '', @@ -311,14 +320,16 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha try { const now = Date.now(); const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); - const dTag = `${slug || 'action'}-${now}`; + const dTag = `${slug || 'pledge'}-${now}`; + const pledgeSats = usdToSats(Number(formData.pledgeUsd), btcPrice); + if (pledgeSats <= 0) throw new Error('Waiting for BTC/USD price to calculate the pledge amount.'); const tags: string[][] = [ ['d', dTag], ['title', formData.title], ['challenge-type', formData.type], - ['bounty', formData.bounty], + ['bounty', String(pledgeSats)], ['t', 'agora-action'], - ['alt', `Agora activist action: ${formData.title}`], + ['alt', `Agora pledge: ${formData.title}`], ]; if (formData.selectedCountry) tags.push(['i', createCountryIdentifier(formData.selectedCountry.toUpperCase())]); if (communityATag) { @@ -358,17 +369,17 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha } setFormData({ - title: '', description: '', type: 'photo', bounty: '', + title: '', description: '', type: 'photo', pledgeUsd: '', startDate: '', startTime: '', deadline: '', time: '', coverImage: DEFAULT_COVER_IMAGE, selectedCountry: countryCode || '', timezone: browserTimezone, }); onOpenChange(false); - toast({ title: 'Action created' }); + toast({ title: 'Pledge created' }); } catch (error) { - console.error('Failed to create action:', error); - toast({ title: 'Failed to create action', variant: 'destructive' }); + console.error('Failed to create pledge:', error); + toast({ title: 'Failed to create pledge', variant: 'destructive' }); } finally { setIsSubmitting(false); } @@ -377,17 +388,17 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha if (!user) return null; const description = communityATag - ? 'New community action. You can optionally choose a country below.' + ? 'New community pledge. You can optionally choose a country below.' : countryCode - ? `New action for ${getGeoDisplayName(countryCode)}.` - : 'New action. You can optionally choose a country below.'; + ? `New pledge for ${getGeoDisplayName(countryCode)}.` + : 'New pledge. You can optionally choose a country below.'; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="h-[85dvh] max-h-[85dvh]"> <DrawerHeader className="text-left"> - <DrawerTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create action</DrawerTitle> + <DrawerTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DrawerTitle> <DrawerDescription>{description}</DrawerDescription> </DrawerHeader> <div className="overflow-y-auto flex-1 pb-safe"> @@ -402,7 +413,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-md sm:max-w-lg md:max-w-2xl max-h-[85vh] w-[calc(100vw-2rem)] sm:w-full overflow-hidden flex flex-col p-0"> <DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0"> - <DialogTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create action</DialogTitle> + <DialogTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DialogTitle> <DialogDescription>{description}</DialogDescription> </DialogHeader> <div className="overflow-y-auto overflow-x-hidden flex-1 min-h-0"> diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 67b7efe1..d048d8c1 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1461,7 +1461,7 @@ const WELL_KNOWN_KIND_LABELS: Record<number, string> = { 3063: 'Zapstore Asset', 15128: 'Nsite', 35128: 'Nsite', - 36639: 'Action', + 36639: 'Pledge', }; export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) { diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index d16ab1b1..eb6a808c 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1898,9 +1898,9 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = { }, 36639: { icon: Megaphone, - action: (event) => publishedAtAction(event, { created: "posted an", updated: "updated an", fallback: "posted an" }), - noun: "action", - nounRoute: "/actions", + action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }), + noun: "pledge", + nounRoute: "/pledges", }, 39089: { icon: PartyPopper, diff --git a/src/hooks/useActions.ts b/src/hooks/useActions.ts index 89fb1128..52472cc7 100644 --- a/src/hooks/useActions.ts +++ b/src/hooks/useActions.ts @@ -4,7 +4,7 @@ import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; /** - * Activist Action (kind 36639) — see `NIP.md`. + * Pledge (kind 36639) — see `NIP.md`. * * Ported from Pathos with two adjustments: * - Discovery `t` tag is canonically `agora-action`. Read aliases include @@ -19,11 +19,12 @@ export interface Action { title: string; description: string; type: 'photo' | 'art' | 'info' | 'action'; + /** Pledged amount in sats. Stored as the legacy `bounty` tag. */ bounty: number; countryCode?: string; /** Unix timestamp — when action becomes active. Defaults to created_at. */ startTime?: number; - /** Unix timestamp — when action expires. Defaults to start + 48h. */ + /** Optional Unix timestamp — when pledge expires. Open-ended when omitted. */ deadline?: number; /** Cover image URL. */ image?: string; @@ -87,14 +88,11 @@ export function parseAction(event: NostrEvent): Action | null { startTimestamp = event.created_at; } - // Deadline: use the tag if valid, otherwise fall back to start + 48h. + // Deadline: use only a valid tag. Pledges are open-ended when omitted. let deadlineTimestamp: number | undefined; if (deadlineTag) { const parsed = parseInt(deadlineTag, 10); - deadlineTimestamp = - !isNaN(parsed) && parsed > 0 ? parsed : startTimestamp + 48 * 60 * 60; - } else { - deadlineTimestamp = startTimestamp + 48 * 60 * 60; + deadlineTimestamp = !isNaN(parsed) && parsed > 0 ? parsed : undefined; } return { @@ -124,12 +122,12 @@ interface UseActionsOptions { } /** - * Returns activist actions (kind 36639), sorted into: - * current actions first (highest bounty, then newest), + * Returns pledges (kind 36639), sorted into: + * current pledges first (highest pledge, then newest), * then upcoming (soonest start first), * then past (most recently expired first). * - * Actions are user-generated. Country filtering only applies when a country + * Pledges are user-generated. Country filtering only applies when a country * code is provided. */ export function useActions({ countryCode, limit = 50 }: UseActionsOptions = {}) { diff --git a/src/hooks/useCommunityActions.ts b/src/hooks/useCommunityActions.ts index 589fd076..9b01dfbd 100644 --- a/src/hooks/useCommunityActions.ts +++ b/src/hooks/useCommunityActions.ts @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { parseAction, type Action } from '@/hooks/useActions'; -/** Fetches kind 36639 actions scoped to a community via the uppercase `A` tag. */ +/** Fetches kind 36639 pledges scoped to a community via the uppercase `A` tag. */ export function useCommunityActions(communityATag: string | undefined) { const { nostr } = useNostr(); diff --git a/src/hooks/useDiscoverFeed.ts b/src/hooks/useDiscoverFeed.ts index 13f6ad0c..3a6dedb7 100644 --- a/src/hooks/useDiscoverFeed.ts +++ b/src/hooks/useDiscoverFeed.ts @@ -17,9 +17,8 @@ const DISCOVER_PAGE_SIZE = 30; * countries (`#K = iso3166`) and comments scoped to communities * (`#K = 34550`). Together these are "posts from the world" + "voices * inside the communities". - * - **36639** — Agora actions (challenges / civic calls). Always - * included because they're the most action-oriented signal on the - * network. + * - **36639** — Agora pledges (challenges / civic calls). Always + * included because they're the most action-oriented funding signal. * * We deliberately *exclude* free-form kind 1 notes here — the Discover * page is the place to see content that's tagged to a real-world thread @@ -30,7 +29,7 @@ const DISCOVER_PAGE_SIZE = 30; /** Tag scopes we accept on kind 1111 comments. */ const COMMENT_K_SCOPES = ['iso3166', 'geo', '34550']; -/** Aliases we accept on kind 36639 action `t` tags. */ +/** Aliases we accept on kind 36639 pledge `t` tags. */ const ACTION_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge']; /** @@ -57,7 +56,7 @@ function filterDiscoverEvents(events: NostrEvent[]): NostrEvent[] { /** * Public infinite feed for the Discover page. Streams together new * campaigns, world-tagged comments, community comments, and Agora - * actions, paginated by `created_at` cursor. + * pledges, paginated by `created_at` cursor. * * Each page issues exactly one relay request (the union of all relevant * filters) to stay inside per-page rate budgets — the same pattern @@ -91,7 +90,7 @@ export function useDiscoverFeed(enabled = true) { limit: DISCOVER_PAGE_SIZE, ...(until && { until }), }, - // Agora actions. + // Agora pledges. { kinds: [36639], '#t': ACTION_T_ALIASES, diff --git a/src/hooks/useSubmissionZapTotals.ts b/src/hooks/useSubmissionZapTotals.ts index 88648ced..28558378 100644 --- a/src/hooks/useSubmissionZapTotals.ts +++ b/src/hooks/useSubmissionZapTotals.ts @@ -1,13 +1,13 @@ import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; -import { extractZapAmount } from '@/hooks/useEventInteractions'; +import { getZapAmountSats } from '@/lib/zapHelpers'; /** - * Batches a single relay query for kind 9735 zap receipts targeting any of the - * supplied event IDs, then returns a `Map<eventId, totalSats>` summing the - * msat amounts per receipt. + * Batches a single relay query for zap/donation receipts targeting any of the + * supplied submission IDs, then returns a `Map<eventId, totalSats>`. * - * Used by `ActionDetailPage` to rank submissions by total zap amount. + * Used by pledge detail pages to rank submissions and calculate open-pool + * funding progress. Kind 9735 carries millisats; kind 8333 carries sats. */ export function useSubmissionZapTotals(eventIds: string[]) { const { nostr } = useNostr(); @@ -22,7 +22,7 @@ export function useSubmissionZapTotals(eventIds: string[]) { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); const receipts = await nostr.query( - [{ kinds: [9735], '#e': eventIds, limit: eventIds.length * 50 }], + [{ kinds: [9735, 8333], '#e': eventIds, limit: eventIds.length * 50 }], { signal }, ); @@ -36,9 +36,8 @@ export function useSubmissionZapTotals(eventIds: string[]) { const matching = targetIds.filter((id) => totals.has(id)); if (matching.length === 0) continue; - const msats = extractZapAmount(receipt); - if (msats <= 0) continue; - const sats = Math.floor(msats / 1000); + const sats = getZapAmountSats(receipt); + if (sats <= 0) continue; for (const id of matching) { totals.set(id, (totals.get(id) ?? 0) + sats); diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 8d40a7ea..a90f77c1 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -608,7 +608,7 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = { 31990: 'app', 30063: 'Zapstore release', 3063: 'Zapstore asset', - 36639: 'action', + 36639: 'pledge', }; /** diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index b7e6af11..7460f151 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -174,7 +174,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ { id: "help", label: "Help", path: "/help", icon: LifeBuoy }, { id: "agent", label: "Agent", path: "/agent", icon: Bot }, // Content types - { id: "actions", label: "Actions", path: "/actions", icon: Megaphone }, + { id: "actions", label: "Pledges", path: "/pledges", icon: Megaphone }, { id: "events", label: "Events", path: "/events", icon: CalendarDays }, { id: "photos", label: "Photos", path: "/photos", icon: Camera }, { id: "videos", label: "Videos", path: "/videos", icon: Film }, diff --git a/src/pages/ActionDetailPage.tsx b/src/pages/ActionDetailPage.tsx index 9a25f80c..f9404024 100644 --- a/src/pages/ActionDetailPage.tsx +++ b/src/pages/ActionDetailPage.tsx @@ -1,27 +1,51 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { format } from 'date-fns'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; -import { Camera, Palette, Info, Megaphone, Clock, Bitcoin, Loader2, MessageSquare, Trophy, ArrowLeft } from 'lucide-react'; -import type { NostrMetadata } from '@nostrify/nostrify'; +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; +import { + ArrowLeft, + CalendarClock, + Camera, + ChevronLeft, + DollarSign, + HandHeart, + Info, + Loader2, + MapPin, + Megaphone, + Palette, + Share2, +} from 'lucide-react'; import { useAction, type Action } from '@/hooks/useActions'; import { useAuthor } from '@/hooks/useAuthor'; +import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; import { useComments } from '@/hooks/useComments'; +import { useEventStats } from '@/hooks/useTrending'; +import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useSubmissionZapTotals } from '@/hooks/useSubmissionZapTotals'; -import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; +import { useToast } from '@/hooks/useToast'; +import { useLayoutOptions } from '@/contexts/LayoutContext'; import { getDisplayName } from '@/lib/genUserName'; -import { getGeoDisplayName, countryCodeToFlag } from '@/lib/countries'; -import { CountryFlag } from '@/components/CountryFlag'; -import { parseCommunityEvent } from '@/lib/communityUtils'; -import { cn } from '@/lib/utils'; +import { getGeoDisplayName } from '@/lib/countries'; +import { formatSats, satsToUSDWhole } from '@/lib/bitcoin'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { ArticleContent } from '@/components/ArticleContent'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Progress } from '@/components/ui/progress'; import { Skeleton } from '@/components/ui/skeleton'; -import { ComposeBox } from '@/components/ComposeBox'; -import { NoteCard } from '@/components/NoteCard'; +import { PostActionBar } from '@/components/PostActionBar'; +import { ReplyComposeModal } from '@/components/ReplyComposeModal'; +import { NoteMoreMenu } from '@/components/NoteMoreMenu'; +import { + InteractionsModal, + type InteractionTab, +} from '@/components/InteractionsModal'; +import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; import NotFound from '@/pages/NotFound'; const ACTION_ICONS = { @@ -31,14 +55,21 @@ const ACTION_ICONS = { action: Megaphone, } as const; -function getCommunityAddr(action: Action): AddrCoords | undefined { - const aTag = action.event.tags.find(([name, value]) => name === 'A' && value?.startsWith('34550:'))?.[1]; - if (!aTag) return undefined; - const [kind, pubkey, ...identifierParts] = aTag.split(':'); - const parsedKind = Number(kind); - const identifier = identifierParts.join(':'); - if (parsedKind !== 34550 || !pubkey || !identifier) return undefined; - return { kind: parsedKind, pubkey, identifier }; +function formatPledgeAmount(sats: number, btcPrice: number | undefined): string { + if (btcPrice) return satsToUSDWhole(sats, btcPrice); + return `${formatSats(sats)} sats`; +} + +function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } { + const now = Math.floor(Date.now() / 1000); + const diff = unixSeconds - now; + if (diff <= 0) { + return { label: `Ended ${new Date(unixSeconds * 1000).toLocaleDateString()}`, isPast: true }; + } + const days = Math.ceil(diff / 86_400); + if (days <= 1) return { label: 'Ends today', isPast: false }; + if (days < 60) return { label: `${days} days left`, isPast: false }; + return { label: `Ends ${new Date(unixSeconds * 1000).toLocaleDateString()}`, isPast: false }; } interface ActionDetailPageProps { @@ -47,284 +78,475 @@ interface ActionDetailPageProps { } export function ActionDetailPage({ pubkey, identifier }: ActionDetailPageProps) { + useLayoutOptions({ noMaxWidth: true, rightSidebar: null }); + const { data: action, isLoading, isError } = useAction(pubkey, identifier); useSeoMeta({ - title: action ? `${action.title} | Agora Action` : 'Action | Agora', + title: action ? `${action.title} | Agora Pledge` : 'Pledge | Agora', description: action?.description?.slice(0, 200), }); - if (isLoading) { - return ( - <main> - <DetailHeader /> - <div className="px-4 max-w-3xl mx-auto space-y-4"> - <Skeleton className="w-full h-56 rounded-2xl" /> - <Skeleton className="h-8 w-3/4" /> - <Skeleton className="h-4 w-1/2" /> - <Skeleton className="h-24 w-full" /> - </div> - </main> - ); - } + if (isLoading) return <PledgeDetailSkeleton />; + if (isError || !action) return <NotFound />; - if (isError || !action) { - return <NotFound />; - } - - return ( - <main> - <DetailHeader /> - <article className="px-4 max-w-3xl mx-auto space-y-6 pb-24"> - <ActionHeader action={action} /> - <ActionBounty action={action} /> - <ActionDescription action={action} /> - <SubmissionsSection action={action} /> - </article> - </main> - ); + return <PledgeDetailContent action={action} />; } -function DetailHeader() { - return ( - <div className="flex items-center gap-4 px-4 py-4 bg-background/85"> - <RouterLink - to="/actions" - className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden" - aria-label="Back to actions" - > - <ArrowLeft className="size-5" /> - </RouterLink> - <div className="flex items-center gap-2 flex-1 min-w-0"> - <Megaphone className="size-5 text-primary" /> - <h1 className="text-lg font-semibold truncate">Action</h1> - </div> - </div> - ); -} - -function ActionHeader({ action }: { action: Action }) { +function PledgeDetailContent({ action }: { action: Action }) { + const { btcPrice } = useBitcoinWallet(); const author = useAuthor(action.pubkey); - const communityAddr = useMemo(() => getCommunityAddr(action), [action]); - const communityEvent = useAddrEvent(communityAddr).data; - const community = communityEvent ? parseCommunityEvent(communityEvent) : null; - const metadata: NostrMetadata | undefined = author.data?.metadata; - const displayName = getDisplayName(metadata, action.pubkey); - const Icon = ACTION_ICONS[action.type]; - const now = Date.now() / 1000; - const isExpired = !!action.deadline && action.deadline <= now; + const navigate = useNavigate(); + const { toast } = useToast(); + const { data: engagementStats } = useEventStats(action.event.id, action.event); + const { data: commentsData, isLoading: commentsLoading } = useComments(action.event, 500); - return ( - <div className="space-y-4"> - {action.image && ( - <div className="relative w-full h-56 sm:h-64 overflow-hidden rounded-2xl border border-border"> - <img src={action.image} alt={action.title} className="w-full h-full object-cover" /> - <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" /> - <div className="absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r from-primary/80 via-primary to-primary/80" /> - </div> - )} - <div className="flex items-start gap-4"> - <div className="p-3 rounded-xl bg-gradient-to-br from-primary/20 to-primary/20 border-2 border-primary/40 shadow-md flex-shrink-0"> - <Icon className="h-7 w-7 text-primary" /> - </div> - <div className="flex-1 min-w-0"> - <h1 className="text-2xl sm:text-3xl font-black leading-tight">{action.title}</h1> - <div className="flex items-center gap-2 mt-2 flex-wrap"> - {action.countryCode && ( - <> - <CountryFlag - code={action.countryCode} - emoji={countryCodeToFlag(action.countryCode)} - label={getGeoDisplayName(action.countryCode)} - className="text-xl" - /> - <span className="text-sm text-muted-foreground">{getGeoDisplayName(action.countryCode)}</span> - </> - )} - {community && communityAddr && ( - <RouterLink - to={`/${nip19.naddrEncode(communityAddr)}`} - className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-semibold text-primary hover:bg-primary/15" - > - <Megaphone className="h-3 w-3" /> - {community.name} - </RouterLink> - )} - {isExpired ? ( - <span className="px-2 py-1 rounded-md bg-muted text-muted-foreground text-xs font-semibold flex items-center gap-1"> - <Clock className="h-3 w-3" /> Expired - </span> - ) : action.deadline ? ( - <span className="px-2 py-1 rounded-md bg-accent/10 border border-accent/30 text-accent text-xs font-semibold flex items-center gap-1"> - <Clock className="h-3 w-3" /> {format(action.deadline * 1000, 'MMM d, yyyy HH:mm')} - </span> - ) : null} - </div> - </div> - </div> - <div className="flex items-center gap-3 text-sm text-muted-foreground"> - <Avatar className="h-8 w-8"> - <AvatarImage src={metadata?.picture} /> - <AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback> - </Avatar> - <span> - Posted by <span className="font-medium text-foreground">{displayName}</span> - </span> - </div> - </div> - ); -} - -function ActionBounty({ action }: { action: Action }) { - return ( - <Card className="border-2 border-primary/40 bg-gradient-to-r from-primary/10 to-primary/5"> - <CardContent className="py-4 flex items-center gap-3"> - <Bitcoin className="h-7 w-7 text-primary flex-shrink-0" /> - <div className="flex-1"> - <div className="text-xs text-muted-foreground uppercase tracking-wider font-semibold"> - Bounty - </div> - <div className="font-black text-2xl"> - {action.bounty.toLocaleString()} - <span className="text-sm font-medium text-muted-foreground ml-1">sats</span> - </div> - </div> - <p className="text-xs text-muted-foreground hidden sm:block max-w-[220px] text-right"> - Submissions are ranked by total zaps. Organizers pay out the bounty by zapping winning submissions. - </p> - </CardContent> - </Card> - ); -} - -function ActionDescription({ action }: { action: Action }) { - if (!action.description.trim()) return null; - return ( - <div className="prose prose-sm max-w-none whitespace-pre-wrap break-words text-foreground/90 leading-relaxed"> - {action.description} - </div> - ); -} - -function SubmissionsSection({ action }: { action: Action }) { - const { data: commentsData, isLoading: commentsLoading } = useComments(action.event); + const [replyOpen, setReplyOpen] = useState(false); + const [moreMenuOpen, setMoreMenuOpen] = useState(false); + const [interactionsOpen, setInteractionsOpen] = useState(false); + const [interactionsTab, setInteractionsTab] = useState<InteractionTab>('reposts'); const topLevel = useMemo( () => commentsData?.topLevelComments ?? [], [commentsData?.topLevelComments], ); const submissionIds = useMemo(() => topLevel.map((c) => c.id), [topLevel]); - const { data: zapTotals } = useSubmissionZapTotals(submissionIds); + const { data: zapTotals, isLoading: zapsLoading } = useSubmissionZapTotals(submissionIds); - // Sort submissions by total sats zapped (descending), with submission - // creation time as the tie-breaker (newest first). - const ranked = useMemo(() => { + const fundedSats = useMemo(() => { const totals = zapTotals ?? new Map<string, number>(); - return [...topLevel].sort((a, b) => { - const aSats = totals.get(a.id) ?? 0; - const bSats = totals.get(b.id) ?? 0; - if (bSats !== aSats) return bSats - aSats; - return b.created_at - a.created_at; - }); + return topLevel.reduce((sum, submission) => sum + (totals.get(submission.id) ?? 0), 0); }, [topLevel, zapTotals]); + const replyTree = useMemo((): ReplyNode[] => { + const totals = zapTotals ?? new Map<string, number>(); + + const buildNode = (ev: NostrEvent): ReplyNode => { + const allChildren = commentsData?.getDirectReplies(ev.id) ?? []; + if (allChildren.length <= 1) { + return { + event: ev, + children: allChildren.map((c) => buildNode(c)), + }; + } + const [first, ...rest] = allChildren; + return { + event: ev, + children: [buildNode(first)], + hiddenChildren: rest.map((c) => buildNode(c)), + }; + }; + + return [...topLevel] + .sort((a, b) => { + const aSats = totals.get(a.id) ?? 0; + const bSats = totals.get(b.id) ?? 0; + if (bSats !== aSats) return bSats - aSats; + return b.created_at - a.created_at; + }) + .map((c) => buildNode(c)); + }, [commentsData, topLevel, zapTotals]); + + const metadata: NostrMetadata | undefined = author.data?.metadata; + const creatorName = getDisplayName(metadata, action.pubkey); + const creatorProfileUrl = useProfileUrl(action.pubkey, metadata); + const deadline = action.deadline ? formatDeadline(action.deadline) : null; + const cover = sanitizeUrl(action.image); + const remainingSats = Math.max(0, action.bounty - fundedSats); + const progressValue = action.bounty > 0 ? Math.min(100, Math.round((fundedSats / action.bounty) * 100)) : 0; + const hasStats = + !!engagementStats?.replies || + !!engagementStats?.reposts || + !!engagementStats?.quotes || + !!engagementStats?.reactions; + + const naddr = nip19.naddrEncode({ + kind: 36639, + pubkey: action.pubkey, + identifier: action.id, + }); + + const storyEvent = useMemo( + () => ({ + ...action.event, + tags: action.event.tags.filter(([name]) => !['image', 'title', 't'].includes(name)), + }), + [action.event], + ); + + const openInteractions = (tab: InteractionTab) => { + setInteractionsTab(tab); + setInteractionsOpen(true); + }; + + const handleShare = async () => { + const url = `${window.location.origin}/${naddr}`; + try { + const nav = typeof navigator !== 'undefined' ? navigator : undefined; + if (nav?.share) { + await nav.share({ title: action.title, text: action.description, url }); + } else if (nav?.clipboard) { + await nav.clipboard.writeText(url); + toast({ title: 'Link copied to clipboard' }); + } + } catch { + // User likely cancelled the share sheet; nothing to do. + } + }; + return ( - <section className="space-y-4"> - <header className="flex items-center justify-between gap-3 pt-2"> - <h2 className="text-lg font-semibold flex items-center gap-2"> - <Trophy className="h-5 w-5 text-primary" /> - Submissions - {topLevel.length > 0 && ( - <span className="text-sm text-muted-foreground font-normal">({topLevel.length})</span> - )} - </h2> - </header> + <main className="min-h-screen pb-16"> + <PledgeHero + action={action} + cover={cover} + creatorName={creatorName} + creatorProfileUrl={creatorProfileUrl} + deadline={deadline} + onBack={() => navigate(-1)} + /> - <ComposeBox compact replyTo={action.event} placeholder="Submit your contribution…" /> - - {commentsLoading ? ( - <SubmissionsSkeleton /> - ) : ranked.length === 0 ? ( - <SubmissionsEmptyState /> - ) : ( - <div className="space-y-3"> - {ranked.map((submission, index) => ( - <RankedSubmission - key={submission.id} - event={submission} - rank={index + 1} - sats={zapTotals?.get(submission.id) ?? 0} + <div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10"> + <div className="lg:flex lg:gap-8 lg:items-start"> + <div className="lg:hidden mb-6"> + <PledgeFundingCard + action={action} + btcPrice={btcPrice} + fundedSats={fundedSats} + remainingSats={remainingSats} + progressValue={progressValue} + submissionsCount={topLevel.length} + isLoading={zapsLoading} + onShare={handleShare} /> - ))} - </div> - )} - </section> - ); -} - -function RankedSubmission({ - event, rank, sats, -}: { event: import('@nostrify/nostrify').NostrEvent; rank: number; sats: number }) { - return ( - <div className="rounded-xl border border-border overflow-hidden"> - <div className="flex items-center gap-2 px-4 py-2 bg-muted/40 text-xs font-semibold"> - <span className={cn( - 'inline-flex items-center justify-center rounded-full px-2 py-0.5 text-[11px]', - rank === 1 && 'bg-amber-500/20 text-amber-600 dark:text-amber-400', - rank === 2 && 'bg-zinc-400/20 text-zinc-700 dark:text-zinc-300', - rank === 3 && 'bg-orange-500/20 text-orange-700 dark:text-orange-400', - rank > 3 && 'bg-muted text-muted-foreground', - )}> - #{rank} - </span> - <span className="flex items-center gap-1 text-muted-foreground"> - <Bitcoin className="size-3" /> - <span className="text-foreground font-bold">{sats.toLocaleString()}</span> - sats zapped - </span> - </div> - <NoteCard event={event} /> - </div> - ); -} - -function SubmissionsSkeleton() { - return ( - <div className="space-y-3"> - {Array.from({ length: 3 }).map((_, i) => ( - <div key={i} className="rounded-xl border border-border p-4 flex gap-3"> - <Skeleton className="size-10 rounded-full" /> - <div className="flex-1 space-y-2"> - <Skeleton className="h-4 w-1/3" /> - <Skeleton className="h-4 w-full" /> - <Skeleton className="h-4 w-4/5" /> </div> + + <div className="flex-1 min-w-0 space-y-8"> + <PledgeStory storyEvent={storyEvent} hasContent={action.description.trim().length > 0} /> + + <div id="pledge-activity" className="scroll-mt-20"> + <div className="rounded-2xl bg-card border border-border/60 shadow-sm px-4 sm:px-5 py-4 sm:py-5"> + {hasStats && ( + <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-foreground pb-2"> + {engagementStats?.reposts ? ( + <button onClick={() => openInteractions('reposts')} className="hover:underline transition-colors"> + <span className="font-bold text-foreground">{engagementStats.reposts.toLocaleString()}</span>{' '} + Repost{engagementStats.reposts !== 1 ? 's' : ''} + </button> + ) : null} + {engagementStats?.quotes ? ( + <button onClick={() => openInteractions('quotes')} className="hover:underline transition-colors"> + <span className="font-bold text-foreground">{engagementStats.quotes.toLocaleString()}</span>{' '} + Quote{engagementStats.quotes !== 1 ? 's' : ''} + </button> + ) : null} + {engagementStats?.reactions ? ( + <button onClick={() => openInteractions('reactions')} className="hover:underline transition-colors"> + <span className="font-bold text-foreground">{engagementStats.reactions.toLocaleString()}</span>{' '} + Like{engagementStats.reactions !== 1 ? 's' : ''} + </button> + ) : null} + </div> + )} + + <PostActionBar + event={action.event} + replyLabel="Submit" + onReply={() => setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + className={hasStats ? 'pt-3 border-t border-border/60' : undefined} + /> + </div> + + <div className="mt-6"> + <div className="flex items-baseline justify-between gap-3 mb-3 px-1"> + <h2 className="text-lg font-semibold tracking-tight">Submissions</h2> + {topLevel.length > 0 ? ( + <span className="text-sm text-muted-foreground tabular-nums"> + {topLevel.length.toLocaleString()} {topLevel.length === 1 ? 'submission' : 'submissions'} + </span> + ) : null} + </div> + + {commentsLoading && replyTree.length === 0 ? ( + <div className="space-y-3"> + {Array.from({ length: 3 }).map((_, i) => <PledgeReplySkeleton key={i} />)} + </div> + ) : replyTree.length > 0 ? ( + <div className="-mx-2 sm:-mx-4 rounded-2xl bg-card border border-border/60 overflow-hidden"> + <ThreadedReplyList roots={replyTree} /> + </div> + ) : ( + <button + type="button" + onClick={() => setReplyOpen(true)} + className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors" + > + <p className="text-base font-medium text-foreground">No submissions yet</p> + <p className="mt-1 text-sm text-muted-foreground"> + Be the first to reply with proof, evidence, or completed work. + </p> + </button> + )} + </div> + </div> + </div> + + <aside className="hidden lg:block lg:w-[360px] lg:shrink-0 lg:self-start"> + <div className="lg:sticky lg:top-4"> + <PledgeFundingCard + action={action} + btcPrice={btcPrice} + fundedSats={fundedSats} + remainingSats={remainingSats} + progressValue={progressValue} + submissionsCount={topLevel.length} + isLoading={zapsLoading} + onShare={handleShare} + /> + </div> + </aside> </div> - ))} + </div> + + <ReplyComposeModal event={action.event} open={replyOpen} onOpenChange={setReplyOpen} /> + <NoteMoreMenu event={action.event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} /> + <InteractionsModal + eventId={action.event.id} + open={interactionsOpen} + onOpenChange={setInteractionsOpen} + initialTab={interactionsTab} + /> + </main> + ); +} + +interface PledgeHeroProps { + action: Action; + cover: string | undefined; + creatorName: string; + creatorProfileUrl: string; + deadline: { label: string; isPast: boolean } | null; + onBack: () => void; +} + +function PledgeHero({ action, cover, creatorName, creatorProfileUrl, deadline, onBack }: PledgeHeroProps) { + const Icon = ACTION_ICONS[action.type]; + const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined; + + return ( + <div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4"> + <div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-primary/40 via-primary/20 to-secondary"> + {cover ? ( + <img src={cover} alt="" className="absolute inset-0 size-full object-cover" /> + ) : ( + <div className="absolute inset-0 flex items-center justify-center"> + <HandHeart className="size-16 text-primary/40" /> + </div> + )} + <div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/45" /> + + <div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 pt-4"> + <button + onClick={onBack} + className="p-2.5 -ml-2 rounded-full text-white/90 hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors" + aria-label="Go back" + > + <ChevronLeft className="size-6 drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" /> + </button> + </div> + + <div className="absolute inset-x-0 bottom-0 z-10 space-y-2 p-5 sm:p-6 [text-shadow:0_1px_4px_rgba(0,0,0,0.75),0_2px_10px_rgba(0,0,0,0.45)]"> + <Badge variant="secondary" className="bg-background/85 text-foreground border-border/40 backdrop-blur [text-shadow:none]"> + <Icon className="size-3.5 mr-1.5" /> + Pledge + </Badge> + <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> + <h1 className="text-3xl sm:text-4xl font-bold leading-tight tracking-tight text-white"> + {action.title} + </h1> + <Link + to={creatorProfileUrl} + onClick={(e) => e.stopPropagation()} + className="text-xs sm:text-sm text-white/85 hover:text-white motion-safe:transition-colors" + > + by <span className="font-medium">{creatorName}</span> + </Link> + </div> + <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs sm:text-sm font-medium text-white/85"> + {countryLabel && ( + <span className="inline-flex items-center gap-1.5"> + <MapPin className="size-3.5 sm:size-4" /> + {countryLabel} + </span> + )} + {deadline ? ( + <span className="inline-flex items-center gap-1.5"> + <CalendarClock className="size-3.5 sm:size-4" /> + {deadline.label} + </span> + ) : ( + <span className="inline-flex items-center gap-1.5"> + <CalendarClock className="size-3.5 sm:size-4" /> + Open-ended + </span> + )} + </div> + {action.description && ( + <p className="max-w-2xl text-base sm:text-lg text-white/90 line-clamp-3"> + {action.description} + </p> + )} + </div> + </div> </div> ); } -function SubmissionsEmptyState() { +function PledgeFundingCard({ + action, + btcPrice, + fundedSats, + remainingSats, + progressValue, + submissionsCount, + isLoading, + onShare, +}: { + action: Action; + btcPrice: number | undefined; + fundedSats: number; + remainingSats: number; + progressValue: number; + submissionsCount: number; + isLoading: boolean; + onShare: () => void; +}) { return ( - <div className="py-10 text-center text-muted-foreground text-sm border border-dashed border-border rounded-xl"> - <MessageSquare className="size-10 mx-auto mb-3 opacity-30" /> - <p className="text-base font-medium mb-1">No submissions yet</p> - <p className="text-xs">Be the first to take action and earn part of the bounty.</p> + <Card className="overflow-hidden"> + <CardContent className="p-5 space-y-5"> + {isLoading ? ( + <Skeleton className="h-28 w-full" /> + ) : ( + <div className="space-y-3"> + <div className="space-y-1"> + <div className="text-2xl font-bold tracking-tight"> + {formatPledgeAmount(fundedSats, btcPrice)} + <span className="ml-1.5 text-sm font-normal text-muted-foreground">funded</span> + </div> + <div className="text-xs text-muted-foreground"> + of {formatPledgeAmount(action.bounty, btcPrice)} pledged + {submissionsCount > 0 && ( + <> + {' · '} + {submissionsCount.toLocaleString()} {submissionsCount === 1 ? 'submission' : 'submissions'} + </> + )} + </div> + </div> + <Progress value={progressValue} className="h-2" /> + <div className="grid grid-cols-2 gap-3 text-sm"> + <div className="rounded-lg bg-muted/40 p-3"> + <div className="text-xs text-muted-foreground">Remaining</div> + <div className="font-semibold">{formatPledgeAmount(remainingSats, btcPrice)}</div> + </div> + <div className="rounded-lg bg-muted/40 p-3"> + <div className="text-xs text-muted-foreground">Stored as</div> + <div className="font-semibold">{formatSats(action.bounty)} sats</div> + </div> + </div> + <p className="text-xs text-muted-foreground leading-relaxed"> + This pledge is trust-based. Funding progress sums zaps and donation receipts on top-level submissions. + </p> + </div> + )} + + <Button variant="outline" size="lg" className="w-full" onClick={onShare}> + <Share2 className="size-4 mr-2" /> + Share + </Button> + </CardContent> + </Card> + ); +} + +function PledgeStory({ storyEvent, hasContent }: { storyEvent: NostrEvent; hasContent: boolean }) { + if (!hasContent) { + return ( + <article className="prose prose-neutral dark:prose-invert max-w-none"> + <p className="text-muted-foreground italic"> + The pledger hasn't written details for this pledge yet. + </p> + </article> + ); + } + + return ( + <article className="prose prose-neutral dark:prose-invert max-w-none"> + <ArticleContent event={storyEvent} /> + </article> + ); +} + +function PledgeReplySkeleton() { + return ( + <div className="py-3 border-b border-border last:border-b-0"> + <div className="flex gap-3"> + <Skeleton className="size-10 rounded-full shrink-0" /> + <div className="flex-1 space-y-2"> + <Skeleton className="h-4 w-32" /> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-2/3" /> + </div> + </div> </div> ); } -/** Loader-state subcomponent used when the addressable coordinate is still - * being decoded (e.g. by NIP19Page). */ -export function ActionDetailLoading() { +function PledgeDetailSkeleton() { return ( - <main> - <DetailHeader /> - <div className="px-4 max-w-3xl mx-auto space-y-4 py-6"> - <div className="flex items-center gap-3"> - <Loader2 className="size-5 animate-spin text-primary" /> - <span className="text-sm text-muted-foreground">Loading action…</span> + <main className="min-h-screen pb-16"> + <div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4"> + <Skeleton className="aspect-[16/9] sm:aspect-[21/9] w-full rounded-xl" /> + </div> + <div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10"> + <div className="lg:flex lg:gap-8 lg:items-start"> + <div className="flex-1 min-w-0 space-y-4"> + <Skeleton className="h-10 w-2/3" /> + <Skeleton className="h-5 w-full" /> + <Skeleton className="h-5 w-4/5" /> + <Skeleton className="h-5 w-5/6" /> + </div> + <div className="hidden lg:block lg:w-[360px] lg:shrink-0 space-y-3"> + <Skeleton className="h-48 w-full rounded-xl" /> + </div> + </div> + </div> + </main> + ); +} + +/** Loader-state subcomponent used when the addressable coordinate is still + * being decoded (e.g. by NIP19Page). */ +export function ActionDetailLoading() { + return ( + <main> + <div className="flex items-center gap-4 px-4 py-4 bg-background/85"> + <Link + to="/pledges" + className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden" + aria-label="Back to pledges" + > + <ArrowLeft className="size-5" /> + </Link> + <div className="flex items-center gap-2 flex-1 min-w-0"> + <DollarSign className="size-5 text-primary" /> + <h1 className="text-lg font-semibold truncate">Pledge</h1> + </div> + </div> + <div className="px-4 max-w-3xl mx-auto space-y-4 py-6"> + <div className="flex items-center gap-3"> + <Loader2 className="size-5 animate-spin text-primary" /> + <span className="text-sm text-muted-foreground">Loading pledge…</span> </div> </div> </main> diff --git a/src/pages/ActionsPage.tsx b/src/pages/ActionsPage.tsx index 2c4cd0cf..8462c02c 100644 --- a/src/pages/ActionsPage.tsx +++ b/src/pages/ActionsPage.tsx @@ -9,12 +9,14 @@ import type { NostrMetadata } from '@nostrify/nostrify'; import { useActions, type Action } from '@/hooks/useActions'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { getAllCountries, getGeoDisplayName, countryCodeToFlag } from '@/lib/countries'; import { CountryFlag } from '@/components/CountryFlag'; import { getDisplayName } from '@/lib/genUserName'; import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; +import { formatSats, satsToUSDWhole } from '@/lib/bitcoin'; import { HOPE_PALETTE } from '@/lib/hopePalette'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { cn } from '@/lib/utils'; @@ -36,7 +38,7 @@ import { } from '@/components/ui/dropdown-menu'; import { - Camera, Palette, Info, Clock, Bitcoin, Plus, ChevronRight, Loader2, + Camera, Palette, Info, Clock, Plus, ChevronRight, Loader2, Link as LinkIcon, Check, MoreHorizontal, Trash2, ListFilter, Calendar, DollarSign, Globe, Megaphone, } from 'lucide-react'; @@ -48,8 +50,9 @@ const ACTION_ICONS = { action: Megaphone, } as const; -function formatSats(sats: number): string { - return sats.toLocaleString(); +function formatPledgeAmount(sats: number, btcPrice: number | undefined): string { + if (btcPrice) return satsToUSDWhole(sats, btcPrice); + return `${formatSats(sats)} sats`; } // ───────────────────────────────────────────────────────────────────────────── @@ -108,7 +111,7 @@ function ActionShareMenu({ action }: { action: Action }) { e.stopPropagation(); if (!user || !isOwner) return; - const confirmed = window.confirm('Delete this action? This cannot be undone.'); + const confirmed = window.confirm('Delete this pledge? This cannot be undone.'); if (!confirmed) return; setIsDeleting(true); @@ -117,7 +120,7 @@ function ActionShareMenu({ action }: { action: Action }) { // honour a-tag-only deletions for addressable events. await createEvent({ kind: 5, - content: 'Deleted action', + content: 'Deleted pledge', tags: [ ['e', action.event.id], ['a', `36639:${action.pubkey}:${action.id}`], @@ -125,10 +128,10 @@ function ActionShareMenu({ action }: { action: Action }) { }); await queryClient.invalidateQueries({ queryKey: ['agora-actions'] }); await queryClient.invalidateQueries({ queryKey: ['agora-action'] }); - toast({ title: 'Action deleted' }); + toast({ title: 'Pledge deleted' }); } catch (error) { - console.error('Failed to delete action:', error); - toast({ title: 'Failed to delete action', variant: 'destructive' }); + console.error('Failed to delete pledge:', error); + toast({ title: 'Failed to delete pledge', variant: 'destructive' }); } finally { setIsDeleting(false); } @@ -154,7 +157,7 @@ function ActionShareMenu({ action }: { action: Action }) { ) : ( <Trash2 className="h-4 w-4 mr-2" /> )} - Delete action + Delete pledge </DropdownMenuItem> <DropdownMenuSeparator /> </> @@ -172,7 +175,7 @@ function ActionShareMenu({ action }: { action: Action }) { ); } -function ActionCard({ action, isExpired }: { action: Action; isExpired?: boolean }) { +function ActionCard({ action, isExpired, btcPrice }: { action: Action; isExpired?: boolean; btcPrice: number | undefined }) { const author = useAuthor(action.pubkey); const metadata: NostrMetadata | undefined = author.data?.metadata; const displayName = getDisplayName(metadata, action.pubkey); @@ -262,11 +265,11 @@ function ActionCard({ action, isExpired }: { action: Action; isExpired?: boolean {action.description} </p> - {/* Meta row: bounty · author. No nested box. */} + {/* Meta row: pledge · author. No nested box. */} <div className="flex items-center gap-2 text-sm pt-1 min-w-0"> - <Bitcoin className="h-4 w-4 text-primary shrink-0" /> - <span className="font-semibold">{formatSats(action.bounty)}</span> - <span className="text-muted-foreground text-xs">sats</span> + <DollarSign className="h-4 w-4 text-primary shrink-0" /> + <span className="font-semibold">{formatPledgeAmount(action.bounty, btcPrice)}</span> + {btcPrice && <span className="text-muted-foreground text-xs">~{formatSats(action.bounty)} sats</span>} <span className="text-muted-foreground/50">·</span> <Avatar className="h-5 w-5 shrink-0"> <AvatarImage src={metadata?.picture} /> @@ -290,6 +293,7 @@ type SortOption = 'recent' | 'bounty' | 'deadline'; export default function ActionsPage() { const { user } = useCurrentUser(); + const { btcPrice } = useBitcoinWallet(); const navigate = useNavigate(); const [selectedCountry, setSelectedCountry] = useState<string | undefined>(undefined); @@ -301,12 +305,12 @@ export default function ActionsPage() { limit: 300, }); - // Route entry points for "Create action" all pass the currently-selected + // 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 - ? `/actions/new?country=${encodeURIComponent(selectedCountry)}` - : '/actions/new'; + ? `/pledges/new?country=${encodeURIComponent(selectedCountry)}` + : '/pledges/new'; // Drive the global FAB from the canonical layout API so we get the same // circular Plus button every other page has. `noMaxWidth: true` lets @@ -338,8 +342,8 @@ export default function ActionsPage() { : 'Global'; useSeoMeta({ - title: `Actions${selectedCountry ? ` — ${selectedCountryName}` : ''} | Agora`, - description: 'Complete activist actions and earn Bitcoin bounties. Take photos, create art, gather information, and take action for change.', + title: `Pledges${selectedCountry ? ` — ${selectedCountryName}` : ''} | Agora`, + description: 'Pledge funding for concrete actions, evidence, or outcomes you want to inspire.', }); const isLoading = actionsLoading; @@ -391,12 +395,12 @@ export default function ActionsPage() { const hasUpcoming = upcomingActions.length > 0; const isOnlyPastView = !hasCurrent && !hasUpcoming && pastActions.length > 0; const primarySectionTitle = hasCurrent - ? 'Active actions' + ? 'Active pledges' : hasUpcoming - ? 'Upcoming actions' + ? 'Upcoming pledges' : pastActions.length > 0 - ? 'Past actions' - : 'Actions'; + ? 'Past pledges' + : 'Pledges'; const deadlineSortLabel = isOnlyPastView ? 'Recently ended' : 'Deadline soon'; const headerControls = ( @@ -414,7 +418,7 @@ export default function ActionsPage() { {sortBy === 'recent' && <Check className="ml-auto h-4 w-4" />} </DropdownMenuItem> <DropdownMenuItem onClick={() => setSortBy('bounty')} className={sortBy === 'bounty' ? 'bg-primary/10' : ''}> - <DollarSign className="mr-2 h-4 w-4" /><span>Highest bounty</span> + <DollarSign className="mr-2 h-4 w-4" /><span>Highest pledge</span> {sortBy === 'bounty' && <Check className="ml-auto h-4 w-4" />} </DropdownMenuItem> <DropdownMenuItem onClick={() => setSortBy('deadline')} className={sortBy === 'deadline' ? 'bg-primary/10' : ''}> @@ -496,6 +500,7 @@ export default function ActionsPage() { showAll={showAllCurrent} onToggle={() => setShowAllCurrent(!showAllCurrent)} isExpired={false} + btcPrice={btcPrice} /> ) : hasUpcoming ? ( <ActionSection @@ -505,6 +510,7 @@ export default function ActionsPage() { showAll={showAllUpcoming} onToggle={() => setShowAllUpcoming(!showAllUpcoming)} isExpired={false} + btcPrice={btcPrice} /> ) : pastActions.length > 0 ? ( <ActionSection @@ -514,6 +520,7 @@ export default function ActionsPage() { showAll={showAllPast} onToggle={() => setShowAllPast(!showAllPast)} isExpired + btcPrice={btcPrice} /> ) : null} @@ -526,6 +533,7 @@ export default function ActionsPage() { showAll={showAllUpcoming} onToggle={() => setShowAllUpcoming(!showAllUpcoming)} isExpired={false} + btcPrice={btcPrice} /> </SectionDivider> )} @@ -539,6 +547,7 @@ export default function ActionsPage() { showAll={showAllPast} onToggle={() => setShowAllPast(!showAllPast)} isExpired + btcPrice={btcPrice} /> </SectionDivider> )} @@ -546,7 +555,7 @@ export default function ActionsPage() { ) : ( <> <div className="flex items-center justify-between mb-6"> - <h2 className="text-xl font-bold">Active actions</h2> + <h2 className="text-xl font-bold">Active pledges</h2> {headerControls} </div> @@ -555,15 +564,15 @@ export default function ActionsPage() { <Megaphone className="size-8 text-primary" /> </div> <div className="space-y-2 max-w-xs"> - <h3 className="text-xl font-bold">No actions yet</h3> + <h3 className="text-xl font-bold">No pledges yet</h3> <p className="text-muted-foreground text-sm"> - {selectedCountry ? `Be the first to create an action for ${selectedCountryName}.` : 'Be the first to create an action.'} + {selectedCountry ? `Be the first to create a pledge for ${selectedCountryName}.` : 'Be the first to create a pledge.'} </p> </div> {user && ( <Button onClick={() => navigate(createActionHref)} className="rounded-full"> <Plus className="size-4 mr-2" /> - Create action + Create pledge </Button> )} </div> @@ -579,11 +588,11 @@ export default function ActionsPage() { // ═══════════════════════════════════════════════════════════════════════════════ /** - * Banner rotation for the Actions hero. We reuse the same gallery the - * action create form offers as a default cover, so the hero feels + * Banner rotation for the Pledges hero. We reuse the same gallery the + * pledge create form offers as a default cover, so the hero feels * thematically continuous with the cards below — readers see the * vocabulary of imagery they'll be picking from when they create their - * own action. Filtered to a single source extension where multiple + * own pledge. Filtered to a single source extension where multiple * exist isn't necessary; the browser handles `.png` / `.jpeg` mixed. */ const ACTIONS_HERO_IMAGES: readonly string[] = DEFAULT_ACTION_COVERS.map( @@ -591,18 +600,18 @@ const ACTIONS_HERO_IMAGES: readonly string[] = DEFAULT_ACTION_COVERS.map( ); interface ActionsHeroProps { - /** Number of actions currently loaded — fuels the live stat pill. */ + /** Number of pledges currently loaded — fuels the live stat pill. */ actionCount: number; - /** When true, the primary CTA opens the create-action dialog. */ + /** When true, the primary CTA opens the create-pledge page. */ canCreate: boolean; /** Fires when the user clicks the primary CTA. */ onCreateAction: () => void; } /** - * Photo-led hero for the Actions index. Same structural recipe as the + * 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 action's "dawn / golden hour" vibe: + * 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. @@ -623,7 +632,7 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp return ( <section className="relative overflow-hidden border-b border-border bg-secondary/30"> {/* Rotating photo banner — uses the same gallery offered as default - action covers, so the hero previews the visual vocabulary of + pledge covers, so the hero previews the visual vocabulary of the cards below. Crossfades every 7s and pans slowly between cuts. */} <HeroBanner images={ACTIONS_HERO_IMAGES} /> @@ -649,22 +658,22 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp <div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-10 sm:py-12 lg:py-14 min-h-[380px] sm:min-h-[420px] lg:min-h-[460px] flex flex-col items-center text-center"> <div className="relative space-y-3 max-w-3xl"> <p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/85 drop-shadow"> - Act + Pledge </p> <h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]"> - Small acts, - <br className="sm:hidden" /> real change. + Inspire the change + <br className="sm:hidden" /> you want to see. </h1> <p className="text-base sm:text-lg text-white/85 max-w-2xl mx-auto drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]"> - Photograph protests, make art, gather information, organize on the ground. - Get paid in Bitcoin when your work moves the needle. + Fund concrete actions, evidence, and outcomes. People reply with submissions, + and the community rewards the work that moves the goal forward. </p> </div> <div className="flex-1 min-h-[100px] sm:min-h-[120px]" aria-hidden="true" /> {/* Live stat pill. Mirrors the Communities hero's pattern but - only carries a single fact — the current action count — + only carries a single fact — the current pledge count — so it stays calm and the headline does the heavy lifting. */} <div className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-amber-500/10" @@ -676,7 +685,7 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp {actionCount.toLocaleString()} </span> <span className="text-xs sm:text-sm text-muted-foreground line-clamp-1"> - {actionCount === 1 ? 'action in motion right now' : 'actions in motion right now'} + {actionCount === 1 ? 'pledge open right now' : 'pledges open right now'} </span> </div> </div> @@ -697,10 +706,10 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp 'motion-safe:transition-colors motion-safe:duration-200', 'disabled:opacity-60 disabled:cursor-not-allowed', )} - aria-label={canCreate ? 'Create action' : 'Log in to create an action'} + aria-label={canCreate ? 'Create pledge' : 'Log in to create a pledge'} > <Plus className="mr-2" /> - Create action + Create pledge </Button> </div> </div> @@ -709,9 +718,9 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp } function ActionSection({ - items, total, visible, showAll, onToggle, isExpired, + items, total, visible, showAll, onToggle, isExpired, btcPrice, }: { - items: Action[]; total: number; visible: number; showAll: boolean; onToggle: () => void; isExpired: boolean; + items: Action[]; total: number; visible: number; showAll: boolean; onToggle: () => void; isExpired: boolean; btcPrice: number | undefined; }) { return ( <div className="space-y-4"> @@ -721,6 +730,7 @@ function ActionSection({ key={`${action.pubkey}:${action.id}`} action={action} isExpired={isExpired} + btcPrice={btcPrice} /> ))} </div> diff --git a/src/pages/CreateActionPage.tsx b/src/pages/CreateActionPage.tsx index 5491dd85..db9d6d91 100644 --- a/src/pages/CreateActionPage.tsx +++ b/src/pages/CreateActionPage.tsx @@ -41,6 +41,7 @@ import { } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import type { Action } from '@/hooks/useActions'; @@ -50,6 +51,7 @@ import { createCountryIdentifier } from '@/lib/countryIdentifiers'; import { getTodayDateInput } from '@/lib/dateInput'; import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { formatSats, satsToUSDWhole, usdToSats } from '@/lib/bitcoin'; import { cn } from '@/lib/utils'; /** @@ -99,14 +101,15 @@ export function CreateActionPage() { const queryClient = useQueryClient(); const { mutateAsync: createEvent } = useNostrPublish(); const { toast } = useToast(); + const { btcPrice } = useBitcoinWallet(); const browserTimezone = useMemo( () => Intl.DateTimeFormat().resolvedOptions().timeZone, [], ); - // ?country=XX lets entry points (the Actions hero CTA, the FAB, and the - // empty-state button) pre-select whichever country the actions index is + // ?country=XX lets entry points (the Pledges hero CTA, the FAB, and the + // empty-state button) pre-select whichever country the pledges index is // currently filtered to — same behavior as the old modal's `countryCode` // prop. const pageCountryCode = searchParams.get('country') || ''; @@ -114,7 +117,7 @@ export function CreateActionPage() { const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [type, setType] = useState<Action['type']>('action'); - const [bounty, setBounty] = useState(''); + const [pledgeUsd, setPledgeUsd] = useState(''); const [startDate, setStartDate] = useState(''); const [startTime, setStartTime] = useState(''); const [deadline, setDeadline] = useState(''); @@ -129,10 +132,16 @@ export function CreateActionPage() { const minDeadline = useMemo(() => getTodayDateInput(), []); useSeoMeta({ - title: 'Create action | Agora', - description: 'Create an activist action and offer a Bitcoin bounty on Agora.', + title: 'Create pledge | Agora', + description: 'Create a donor pledge to inspire concrete action on Agora.', }); + const pledgeSatsPreview = useMemo(() => { + const n = Number(pledgeUsd); + if (!Number.isFinite(n) || n <= 0) return 0; + return usdToSats(n, btcPrice); + }, [btcPrice, pledgeUsd]); + const allCountries = useMemo(() => getAllCountries(), []); const countryOptions = useMemo(() => { @@ -160,18 +169,22 @@ export function CreateActionPage() { const submitMutation = useMutation({ mutationFn: async () => { - if (!user) throw new Error('You must be logged in to create an action.'); + if (!user) throw new Error('You must be logged in to create a pledge.'); const trimmedTitle = title.trim(); const trimmedDescription = description.trim(); if (!trimmedTitle) throw new Error('Title is required.'); if (!trimmedDescription) throw new Error('Description is required.'); - if (!bounty.trim()) throw new Error('Bounty is required.'); + if (!pledgeUsd.trim()) throw new Error('Pledge amount is required.'); - const bountyNum = Number(bounty); - if (!Number.isFinite(bountyNum) || bountyNum <= 0) { - throw new Error('Bounty must be a positive number of sats.'); + const pledgeUsdNum = Number(pledgeUsd); + if (!Number.isFinite(pledgeUsdNum) || pledgeUsdNum <= 0) { + throw new Error('Pledge amount must be a positive USD amount.'); + } + const pledgeSats = usdToSats(pledgeUsdNum, btcPrice); + if (pledgeSats <= 0) { + throw new Error('Waiting for BTC/USD price to calculate the pledge amount.'); } const now = Date.now(); @@ -179,15 +192,15 @@ export function CreateActionPage() { .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); - const dTag = `${slug || 'action'}-${now}`; + const dTag = `${slug || 'pledge'}-${now}`; const tags: string[][] = [ ['d', dTag], ['title', trimmedTitle], ['challenge-type', type], - ['bounty', String(bountyNum)], + ['bounty', String(pledgeSats)], ['t', 'agora-action'], - ['alt', `Agora activist action: ${trimmedTitle}`], + ['alt', `Agora pledge: ${trimmedTitle}`], ]; if (selectedCountry) { @@ -233,14 +246,14 @@ export function CreateActionPage() { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['agora-actions'] }); await queryClient.refetchQueries({ queryKey: ['agora-actions'] }); - toast({ title: 'Action created' }); - navigate('/actions'); + toast({ title: 'Pledge created' }); + navigate('/pledges'); }, onError: (error: unknown) => { const msg = error instanceof Error ? error.message : String(error); setFormError(msg); toast({ - title: 'Could not create action', + title: 'Could not create pledge', description: msg, variant: 'destructive', }); @@ -254,12 +267,12 @@ export function CreateActionPage() { <Card> <CardContent className="py-12 px-8 text-center space-y-4"> <Megaphone className="size-10 text-muted-foreground/60 mx-auto" /> - <h2 className="text-xl font-semibold">Log in to create an action</h2> + <h2 className="text-xl font-semibold">Log in to create a pledge</h2> <p className="text-muted-foreground"> - Actions are signed Nostr events. You need a Nostr login to publish one. + Pledges are signed Nostr events. You need a Nostr login to publish one. </p> <Button asChild> - <Link to="/actions">Back to actions</Link> + <Link to="/pledges">Back to pledges</Link> </Button> </CardContent> </Card> @@ -271,7 +284,8 @@ export function CreateActionPage() { const canSubmit = title.trim().length > 0 && description.trim().length > 0 && - bounty.trim().length > 0 && + pledgeUsd.trim().length > 0 && + pledgeSatsPreview > 0 && !coverUploading && !submitMutation.isPending; @@ -296,7 +310,7 @@ export function CreateActionPage() { <ArrowLeft className="size-5" /> </button> <h1 className="text-2xl sm:text-3xl font-bold tracking-tight"> - Create action + Create pledge </h1> </div> </div> @@ -341,29 +355,35 @@ export function CreateActionPage() { </SelectItem> <SelectItem value="action"> <div className="flex items-center gap-2"> - <Megaphone className="h-4 w-4" /> Action + <Megaphone className="h-4 w-4" /> Direct action </div> </SelectItem> </SelectContent> </Select> </FormSection> - {/* Bounty */} - <FormSection title="Bounty (sats)" requirement="Required"> + {/* Pledge amount */} + <FormSection title="Pledge amount (USD)" requirement="Required"> <Input type="number" - placeholder="10000" - value={bounty} - onChange={(e) => setBounty(e.target.value)} + placeholder="100" + value={pledgeUsd} + onChange={(e) => setPledgeUsd(e.target.value)} min={1} + step="0.01" /> + <p className="text-xs text-muted-foreground"> + {pledgeSatsPreview > 0 && btcPrice + ? `${formatSats(pledgeSatsPreview)} sats will be stored on the event (${satsToUSDWhole(pledgeSatsPreview, btcPrice)} at the current rate).` + : 'Enter a USD amount. Agora stores the pledge in sats on Nostr.'} + </p> </FormSection> </div> {/* Description */} <FormSection title="Description" requirement="Required"> <Textarea - placeholder="Explain what submissions should look like, why this matters, and how the bounty will be paid out..." + placeholder="Explain the action, evidence, or outcome you want to inspire, what submissions should include, and how you plan to evaluate them..." className="min-h-[120px]" value={description} onChange={(e) => setDescription(e.target.value)} @@ -477,7 +497,7 @@ export function CreateActionPage() { /> )} <p className="text-xs text-muted-foreground"> - {!deadline && 'Defaults to 48 hours after start'} + {!deadline && 'Open-ended. Add a deadline if urgency matters.'} {deadline && !deadlineTime && 'Ends at 23:59 local time'} </p> </FormSection> @@ -524,7 +544,7 @@ export function CreateActionPage() { ) : ( <> <Plus className="size-4 mr-2" /> - Create action + Create pledge </> )} </Button> diff --git a/src/pages/DiscoverPage.tsx b/src/pages/DiscoverPage.tsx index f105f63f..48912aa6 100644 --- a/src/pages/DiscoverPage.tsx +++ b/src/pages/DiscoverPage.tsx @@ -207,7 +207,7 @@ export function DiscoverPage() { </h2> <p className="text-sm text-muted-foreground mt-1 max-w-xl"> New campaigns, posts tagged to a country, comments inside - communities, and on-the-ground actions — one timeline, sorted + communities, and donor pledges — one timeline, sorted by what just happened. </p> </div> @@ -303,7 +303,7 @@ function DiscoverFeed() { * Single row inside the Discover feed. Campaign events (kind 30223) get * the full `CampaignCard` treatment so their banner and progress show; * everything else routes through `NoteCard`, which already handles - * kind 1111 comments, kind 36639 actions, and the long tail. + * kind 1111 comments, kind 36639 pledges, and the long tail. */ function DiscoverFeedRow({ event }: { event: NostrEvent }) { if (event.kind === CAMPAIGN_KIND) {