Flatten campaign form details

This commit is contained in:
lemon
2026-05-17 22:54:45 -07:00
parent 58ca29fb62
commit f665ffa0c0
5 changed files with 124 additions and 140 deletions
+3 -2
View File
@@ -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:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
+4 -3
View File
@@ -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
<HandHeart className="size-12 text-primary/40" />
</div>
)}
{campaign.category && (
{tagLabel && (
<Badge
variant="secondary"
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
>
{CAMPAIGN_CATEGORY_LABELS[campaign.category]}
{tagLabel}
</Badge>
)}
{campaign.archived && (
+28
View File
@@ -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<string>();
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;
+4 -3
View File
@@ -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 }) {
</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">
{campaign.category && (
{tagLabel && (
<span className="inline-flex items-center gap-1.5">
<Tag className="size-3.5 sm:size-4" />
{CAMPAIGN_CATEGORY_LABELS[campaign.category]}
{tagLabel}
</span>
)}
{countryLabel && (
+85 -132
View File
@@ -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<string>();
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<CampaignCategory>('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() {
</FormSection>
{/* Country */}
<FormSection title="Country" requirement="Recommended" description="Helps people discover and sort campaigns by country.">
<FormSection title="Country" requirement="Recommended" description="Help discover campaigns by country.">
<CountrySelect
query={countryQuery}
selectedCode={countryCode}
@@ -637,6 +642,19 @@ export function CreateCampaignPage() {
/>
</FormSection>
{/* Tags */}
<FormSection title="Tags" requirement="Recommended" description="Comma-separated topics for discovery. You do not need to include #.">
<Input
id="campaign-tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="human rights, legal defense, independent media"
/>
<p className="text-xs text-muted-foreground">
Published as Nostr <span className="font-mono text-foreground">t</span> tags.
</p>
</FormSection>
{/* Recipients */}
<FormSection
title="Beneficiaries"
@@ -708,84 +726,58 @@ export function CreateCampaignPage() {
/>
</FormSection>
{/* Optional details */}
<CollapsibleFormSection
title="Details"
requirement="Optional"
description="Extra context for donors."
>
<div className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="campaign-summary">Summary</Label>
<Textarea
id="campaign-summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="A short pitch for cards and previews."
rows={2}
maxLength={300}
/>
</div>
{/* Summary */}
<FormSection title="Summary" requirement="Optional" description="A short pitch for cards and previews.">
<Textarea
id="campaign-summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="Help our neighborhood legal clinic defend peaceful protesters."
rows={2}
maxLength={300}
/>
</FormSection>
<div className="space-y-1.5">
<Label htmlFor="campaign-story">Story</Label>
<Textarea
id="campaign-story"
value={story}
onChange={(e) => setStory(e.target.value)}
placeholder="Tell donors why this matters. Markdown is supported."
rows={7}
className="font-mono text-sm"
/>
</div>
{/* Story */}
<FormSection title="Story" requirement="Optional" description="Tell donors why this matters. Markdown is supported.">
<Textarea
id="campaign-story"
value={story}
onChange={(e) => setStory(e.target.value)}
placeholder="Share the background, who benefits, and how funds will be used."
rows={7}
className="font-mono text-sm"
/>
</FormSection>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="campaign-category">Category</Label>
<Select
value={category}
onValueChange={(v) => setCategory(v as CampaignCategory)}
>
<SelectTrigger id="campaign-category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CAMPAIGN_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{CAMPAIGN_CATEGORY_LABELS[cat]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="campaign-goal">Goal (USD)</Label>
<Input
id="campaign-goal"
type="text"
inputMode="decimal"
placeholder="100,000"
value={goalUsd}
onChange={(e) => setGoalUsd(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{goalSatsPreview > 0 && btcPrice
? `${formatSats(goalSatsPreview)} sats (${satsToUSDWhole(goalSatsPreview, btcPrice)}).`
: 'Stored as sats.'}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="campaign-deadline">Deadline (optional)</Label>
<Input
id="campaign-deadline"
type="date"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
/>
</div>
</div>
</div>
</CollapsibleFormSection>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{/* Goal */}
<FormSection title="Goal" requirement="Optional" description="Set a target amount for donors to rally around.">
<Input
id="campaign-goal"
type="text"
inputMode="decimal"
placeholder="100,000"
value={goalUsd}
onChange={(e) => setGoalUsd(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{goalSatsPreview > 0 && btcPrice
? `${formatSats(goalSatsPreview)} sats (${satsToUSDWhole(goalSatsPreview, btcPrice)}).`
: 'Stored as sats.'}
</p>
</FormSection>
{/* Deadline */}
<FormSection title="Deadline" requirement="Optional" description="Show donors when the campaign is time-sensitive.">
<Input
id="campaign-deadline"
type="date"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
/>
</FormSection>
</div>
</div>
{formError && (
@@ -847,41 +839,6 @@ function FormSection({
);
}
function CollapsibleFormSection({
title,
requirement,
description,
children,
}: {
title: string;
requirement: 'Required' | 'Recommended' | 'Optional';
description?: string;
children: React.ReactNode;
}) {
return (
<Collapsible className="rounded-xl" defaultOpen={false}>
<CollapsibleTrigger
type="button"
className="group flex w-full items-start justify-between gap-4 p-3 text-left sm:p-4"
>
<div className="space-y-0.5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
{title}
<span className="text-xs font-medium text-muted-foreground">
{requirement}
</span>
</h2>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
<ChevronDown className="mt-1 size-5 shrink-0 text-muted-foreground motion-safe:transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 sm:px-4 sm:pb-4">
{children}
</CollapsibleContent>
</Collapsible>
);
}
function CountrySelect({
query,
selectedCode,
@@ -986,14 +943,10 @@ function CountrySelect({
)}
</div>
{selectedCountry ? (
{selectedCountry && (
<p className="text-xs text-muted-foreground">
Publishes <span className="font-mono text-foreground">i: iso3166:{selectedCode}</span> for country sorting.
</p>
) : (
<p className="text-xs text-muted-foreground">
Start typing a country name, then choose one from the list.
</p>
)}
</div>
);