From f665ffa0c06d55fe09e7565b2182d09451fb6a80 Mon Sep 17 00:00:00 2001 From: lemon Date: Sun, 17 May 2026 22:54:45 -0700 Subject: [PATCH] Flatten campaign form details --- NIP.md | 5 +- src/components/CampaignCard.tsx | 7 +- src/lib/campaign.ts | 28 ++++ src/pages/CampaignDetailPage.tsx | 7 +- src/pages/CreateCampaignPage.tsx | 217 ++++++++++++------------------- 5 files changed, 124 insertions(+), 140 deletions(-) diff --git a/NIP.md b/NIP.md index 8902b22b..8bb9660c 100644 --- a/NIP.md +++ b/NIP.md @@ -197,7 +197,8 @@ The kind is addressable so the creator can edit the story, image, goal, deadline ["title", "Save the Last Bookstore"], ["summary", "Help our 40-year-old neighborhood bookstore make rent through winter."], ["image", "https://example.com/cover.jpg"], - ["t", "community"], + ["t", "human-rights"], + ["t", "legal-defense"], ["goal", "10000000"], ["deadline", "1735689600"], ["i", "iso3166:VE"], @@ -222,7 +223,7 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH | `title` | Yes | Display title of the campaign (plain text, max ~200 chars). | | `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. | | `image` | Recommended | HTTPS URL of the cover image (jpg/png/webp). Clients MUST sanitize and verify the URL before rendering. | -| `t` | Recommended | Category. SHOULD be one of: `medical`, `memorial`, `emergency`, `education`, `animals`, `community`, `sports`, `creative`, `business`, `faith`, `other`. Multiple `t` tags MAY be used. | +| `t` | Recommended | Topic tag for discovery and filtering (e.g. `human-rights`, `legal-defense`, `independent-media`). Multiple `t` tags MAY be used. Clients SHOULD normalize user-entered tag labels by removing a leading `#`, lowercasing, and replacing whitespace with hyphens. | | `goal` | Recommended | Fundraising goal in **satoshis** (decimal integer). Omit if the campaign has no fixed goal. | | `deadline` | Optional | Unix timestamp (seconds) at which the campaign closes. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. | | `i` | Recommended | NIP-73 country identifier for sorting and discovery. SHOULD be `iso3166:` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). | diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx index 9cb18a95..ceebcdbc 100644 --- a/src/components/CampaignCard.tsx +++ b/src/components/CampaignCard.tsx @@ -10,10 +10,10 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useCampaignDonations } from '@/hooks/useCampaignDonations'; import { - CAMPAIGN_CATEGORY_LABELS, type ParsedCampaign, encodeCampaignNaddr, getCampaignCountryLabel, + getCampaignPrimaryTagLabel, } from '@/lib/campaign'; import { formatCampaignAmount } from '@/lib/formatCampaignAmount'; import { genUserName } from '@/lib/genUserName'; @@ -84,6 +84,7 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null; const raisedSats = stats?.totalSats ?? 0; const countryLabel = getCampaignCountryLabel(campaign); + const tagLabel = getCampaignPrimaryTagLabel(campaign); const isFeatured = variant === 'featured'; @@ -120,12 +121,12 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa )} - {campaign.category && ( + {tagLabel && ( - {CAMPAIGN_CATEGORY_LABELS[campaign.category]} + {tagLabel} )} {campaign.archived && ( diff --git a/src/lib/campaign.ts b/src/lib/campaign.ts index aaa9189a..d9be6777 100644 --- a/src/lib/campaign.ts +++ b/src/lib/campaign.ts @@ -85,6 +85,8 @@ export interface ParsedCampaign { image?: string; /** Category slug from the first `t` tag matching a known category, or `undefined`. */ category?: CampaignCategory; + /** Campaign tags parsed from all `t` tags, in event order. */ + tags: string[]; /** Goal in satoshis, or `undefined` if not set. */ goalSats?: number; /** Deadline (Unix seconds), or `undefined` if not set. */ @@ -128,6 +130,19 @@ function getCountryCode(event: NostrEvent): string | undefined { return undefined; } +function getCampaignTags(event: NostrEvent): string[] { + const seen = new Set(); + const tags: string[] = []; + for (const [name, value] of event.tags) { + if (name !== 't' || typeof value !== 'string') continue; + const tag = value.trim(); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + } + return tags; +} + /** * Parses a kind 30223 event into a strongly-typed campaign, or returns * `null` if the event is missing required fields (title, `d` tag, or at @@ -188,6 +203,7 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null { // ignored so future statuses (e.g. `paused`, `funded`) don't accidentally // get treated as archived. const archived = getTag(event, 'status') === 'archived'; + const tags = getCampaignTags(event); return { event, @@ -199,6 +215,7 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null { story: event.content, image, category, + tags, goalSats: parsePositiveInt(getTag(event, 'goal')), deadline: parsePositiveInt(getTag(event, 'deadline')), location: getTag(event, 'location')?.trim() || undefined, @@ -216,6 +233,17 @@ export function getCampaignCountryLabel(campaign: ParsedCampaign): string | unde return campaign.location; } +export function getCampaignPrimaryTagLabel(campaign: ParsedCampaign): string | undefined { + const firstTag = campaign.tags[0] ?? campaign.category; + if (!firstTag) return undefined; + if ((CAMPAIGN_CATEGORIES as readonly string[]).includes(firstTag)) { + return CAMPAIGN_CATEGORY_LABELS[firstTag as CampaignCategory]; + } + const legacyCategory = LEGACY_CAMPAIGN_CATEGORY_ALIASES[firstTag]; + if (legacyCategory) return CAMPAIGN_CATEGORY_LABELS[legacyCategory]; + return firstTag.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()); +} + /** Output of {@link splitDonation}: per-recipient amounts in sats. */ export interface DonationSplit { pubkey: string; diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 38108b4f..a7ec1187 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -53,9 +53,9 @@ import { useEventStats } from '@/hooks/useTrending'; import { useToast } from '@/hooks/useToast'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { - CAMPAIGN_CATEGORY_LABELS, encodeCampaignNaddr, getCampaignCountryLabel, + getCampaignPrimaryTagLabel, type ParsedCampaign, } from '@/lib/campaign'; import { satsToUSDWhole } from '@/lib/bitcoin'; @@ -234,6 +234,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null; const countryLabel = getCampaignCountryLabel(campaign); + const tagLabel = getCampaignPrimaryTagLabel(campaign); const raisedSats = stats?.totalSats ?? 0; const isCreator = user?.pubkey === campaign.pubkey; @@ -381,10 +382,10 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
- {campaign.category && ( + {tagLabel && ( - {CAMPAIGN_CATEGORY_LABELS[campaign.category]} + {tagLabel} )} {countryLabel && ( diff --git a/src/pages/CreateCampaignPage.tsx b/src/pages/CreateCampaignPage.tsx index 35609c91..ca23b183 100644 --- a/src/pages/CreateCampaignPage.tsx +++ b/src/pages/CreateCampaignPage.tsx @@ -9,7 +9,6 @@ import { nip19 } from 'nostr-tools'; import { AlertTriangle, ArrowLeft, - ChevronDown, HandHeart, ImagePlus, Loader2, @@ -25,16 +24,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { parseAuthorEvent } from '@/hooks/useAuthor'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; @@ -47,12 +38,9 @@ import type { SearchProfile } from '@/hooks/useSearchProfiles'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { formatSats, satsToUSDWhole, usdToSats } from '@/lib/bitcoin'; import { - CAMPAIGN_CATEGORIES, - CAMPAIGN_CATEGORY_LABELS, CAMPAIGN_KIND, encodeCampaignNaddr, parseCampaign, - type CampaignCategory, slugifyCampaignIdentifier, } from '@/lib/campaign'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; @@ -171,6 +159,22 @@ function formatGoalUsd(goalSats: number | undefined, btcPrice: number | undefine return usd.toFixed(usd >= 100 ? 0 : 2); } +function normalizeCampaignTag(value: string): string { + return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-'); +} + +function parseCampaignTagInput(value: string): string[] { + const seen = new Set(); + const tags: string[] = []; + for (const part of value.split(',')) { + const tag = normalizeCampaignTag(part); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + } + return tags; +} + function getExactCountryCode(query: string): string | undefined { const match = searchCountry(query); return match?.exact ? match.country.code : undefined; @@ -198,7 +202,7 @@ export function CreateCampaignPage() { const [summary, setSummary] = useState(''); const [story, setStory] = useState(''); const [imageUrl, setImageUrl] = useState(''); - const [category, setCategory] = useState('human-rights'); + const [tagInput, setTagInput] = useState(''); const [goalUsd, setGoalUsd] = useState(''); const [deadline, setDeadline] = useState(''); const [countryQuery, setCountryQuery] = useState(''); @@ -290,7 +294,7 @@ export function CreateCampaignPage() { setSummary(editCampaign.summary); setStory(editCampaign.story); setImageUrl(editCampaign.image ?? ''); - setCategory(editCampaign.category ?? 'human-rights'); + setTagInput(editCampaign.tags.join(', ')); setDeadline(formatDateInput(editCampaign.deadline)); const editCountryCode = editCampaign.countryCode ?? getExactCountryCode(editCampaign.location ?? '') ?? ''; setCountryCode(editCountryCode); @@ -408,6 +412,7 @@ export function CreateCampaignPage() { } const resolvedCountryCode = countryCode || getExactCountryCode(countryQuery); + const campaignTags = parseCampaignTagInput(tagInput); let prev: NostrEvent | null = null; if (isEditMode) { @@ -439,10 +444,10 @@ export function CreateCampaignPage() { const tags: string[][] = [ ['d', slug], ['title', trimmedTitle], - ['t', category], ['alt', `Fundraising campaign: ${trimmedTitle}`], ]; if (summary.trim()) tags.splice(2, 0, ['summary', summary.trim()]); + for (const tag of campaignTags) tags.push(['t', tag]); if (sanitizedImage) tags.push(['image', sanitizedImage]); if (goalNum !== undefined) tags.push(['goal', String(goalNum)]); if (deadlineNum !== undefined) tags.push(['deadline', String(deadlineNum)]); @@ -615,7 +620,7 @@ export function CreateCampaignPage() { {/* Country */} - + + {/* Tags */} + + setTagInput(e.target.value)} + placeholder="human rights, legal defense, independent media" + /> +

+ Published as Nostr t tags. +

+
+ {/* Recipients */} - {/* Optional details */} - -
-
- -