Flatten campaign form details
This commit is contained in:
@@ -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`). |
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user