Reframe actions as pledges

This commit is contained in:
lemon
2026-05-19 22:13:38 -07:00
parent 75a3453daa
commit 3dd229edfb
18 changed files with 689 additions and 423 deletions
+19 -19
View File
@@ -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:<pubkey>:<d>`. |
| `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:<pubkey>:<d-tag>`. |
| `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:<pubkey>:<d-tag>`. |
| `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: <title>"`. |
| `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 }
+2
View File
@@ -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 />} />
+10 -5
View File
@@ -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>
+1 -1
View File
@@ -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',
+3 -3
View File
@@ -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">
+32 -21
View File
@@ -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">
+1 -1
View File
@@ -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 } }) {
+3 -3
View File
@@ -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,
+8 -10
View File
@@ -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 = {}) {
+1 -1
View File
@@ -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();
+5 -6
View File
@@ -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,
+8 -9
View File
@@ -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);
+1 -1
View File
@@ -608,7 +608,7 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
31990: 'app',
30063: 'Zapstore release',
3063: 'Zapstore asset',
36639: 'action',
36639: 'pledge',
};
/**
+1 -1
View File
@@ -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 },
+485 -263
View File
@@ -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>
+57 -47
View File
@@ -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>
+50 -30
View File
@@ -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>
+2 -2
View File
@@ -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) {