Reframe actions as pledges
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 } }) {
|
||||
|
||||
@@ -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
@@ -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 = {}) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -608,7 +608,7 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
|
||||
31990: 'app',
|
||||
30063: 'Zapstore release',
|
||||
3063: 'Zapstore asset',
|
||||
36639: 'action',
|
||||
36639: 'pledge',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user