Add country tags to campaigns

This commit is contained in:
lemon
2026-05-17 22:38:25 -07:00
parent 735de6ece9
commit ba2c541c31
9 changed files with 246 additions and 41 deletions
+6 -3
View File
@@ -179,7 +179,7 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
### Summary
Addressable event representing a **fundraising campaign**. A campaign carries the marketing-style metadata you would expect on GoFundMe, Kickstarter, or GiveSendGo (title, summary, cover image, story, category, goal, optional deadline and location), and — most importantly — a list of recipient pubkeys (`p` tags) that share the proceeds of any donation.
Addressable event representing a **fundraising campaign**. A campaign carries the marketing-style metadata you would expect on GoFundMe, Kickstarter, or GiveSendGo (title, summary, cover image, story, category, goal, optional deadline, and recommended country), and — most importantly — a list of recipient pubkeys (`p` tags) that share the proceeds of any donation.
Donations are sent as a **single Bitcoin on-chain transaction** with one output per recipient. The donor's wallet derives each recipient's Taproot address from their pubkey via BIP-340/BIP-341 (the same scheme used by kind 8333 onchain zaps), so the campaign event itself does not need to carry Bitcoin addresses. After broadcasting the funding tx, the donor's client publishes one kind 8333 event per recipient, all referencing the same `txid` and tagging the campaign via `a` / `K`, so the donation shows up in the campaign's totals and in each recipient's profile zap history.
@@ -200,7 +200,8 @@ The kind is addressable so the creator can edit the story, image, goal, deadline
["t", "community"],
["goal", "10000000"],
["deadline", "1735689600"],
["location", "Portland, OR"],
["i", "iso3166:VE"],
["k", "iso3166"],
["p", "<recipient-1-hex-pubkey>", "wss://relay.example", "2"],
["p", "<recipient-2-hex-pubkey>", "wss://relay.example", "1"],
["p", "<recipient-3-hex-pubkey>"],
@@ -224,7 +225,9 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH
| `t` | Recommended | Category. SHOULD be one of: `medical`, `memorial`, `emergency`, `education`, `animals`, `community`, `sports`, `creative`, `business`, `faith`, `other`. Multiple `t` tags MAY be used. |
| `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. |
| `location` | Optional | Human-readable location string (e.g. "Portland, OR" or "Online"). For machine-readable geo, a `g` (geohash) tag MAY be added in parallel. |
| `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`). |
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
| `location` | Legacy | Human-readable location string used by older campaign events. New events SHOULD prefer `i` + `k` country tags. Clients MAY display this as a fallback only. |
| `status` | Optional | Lifecycle status. The only defined value is `archived`, which marks the campaign closed without deleting it. Other values SHOULD be ignored. See *Closing & archiving* below. |
| `p` | Yes (≥1) | Recipient pubkey. The 2nd element is the hex pubkey; the 3rd (optional) is a relay hint; the 4th (optional) is a positive decimal **weight** for split allocation. |
| `alt` | Recommended | NIP-31 human-readable fallback. |
+4 -2
View File
@@ -13,6 +13,7 @@ import {
CAMPAIGN_CATEGORY_LABELS,
type ParsedCampaign,
encodeCampaignNaddr,
getCampaignCountryLabel,
} from '@/lib/campaign';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
@@ -82,6 +83,7 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
genUserName(campaign.pubkey);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
const isFeatured = variant === 'featured';
@@ -177,10 +179,10 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
</span>
)}
{campaign.location && (
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{campaign.location}
{countryLabel}
</span>
)}
{deadline && (
+4 -3
View File
@@ -4,7 +4,7 @@ import { ArrowRight, MapPin } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import { encodeCampaignNaddr, type ParsedCampaign } from '@/lib/campaign';
import { encodeCampaignNaddr, getCampaignCountryLabel, type ParsedCampaign } from '@/lib/campaign';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@@ -58,6 +58,7 @@ export function HeroCampaignSpotlight({
const meta = author.data?.metadata;
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
const authorPicture = sanitizeUrl(meta?.picture);
const countryLabel = getCampaignCountryLabel(campaign);
return (
<div
@@ -122,10 +123,10 @@ export function HeroCampaignSpotlight({
</Avatar>
<span className="font-medium">{authorName}</span>
</span>
{campaign.location && (
{countryLabel && (
<span className="inline-flex items-center gap-1">
<MapPin className="size-3" />
<span className="truncate max-w-[16ch]">{campaign.location}</span>
<span className="truncate max-w-[16ch]">{countryLabel}</span>
</span>
)}
<Link
+6 -2
View File
@@ -3,10 +3,13 @@ import { useQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { CAMPAIGN_KIND, type CampaignCategory, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
interface UseCampaignsOptions {
/** Optional category filter (`t` tag). */
category?: CampaignCategory;
/** Optional ISO 3166-1 alpha-2 country filter (`i` tag). */
countryCode?: string;
/** Maximum number of events to fetch from relays. Default: 60. */
limit?: number;
/** Authors to fetch from, e.g. for a profile's campaigns. */
@@ -40,16 +43,17 @@ interface UseCampaignsOptions {
*/
export function useCampaigns(options: UseCampaignsOptions = {}) {
const { nostr } = useNostr();
const { category, limit = 60, authors, recipientPubkeys, includeArchived = false } = options;
const { category, countryCode, limit = 60, authors, recipientPubkeys, includeArchived = false } = options;
return useQuery({
queryKey: [
'campaigns',
{ category, limit, authors, recipientPubkeys, includeArchived },
{ category, countryCode, limit, authors, recipientPubkeys, includeArchived },
],
queryFn: async (c) => {
const filter: NostrFilter = { kinds: [CAMPAIGN_KIND], limit };
if (category) filter['#t'] = [category];
if (countryCode) filter['#i'] = [createCountryIdentifier(countryCode)];
if (authors && authors.length > 0) filter.authors = authors;
if (recipientPubkeys && recipientPubkeys.length > 0) {
filter['#p'] = recipientPubkeys;
+22
View File
@@ -1,6 +1,9 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { COUNTRIES } from '@/lib/countries';
import { parseCountryIdentifier } from '@/lib/countryIdentifiers';
/** Addressable kind number for fundraising campaigns (see NIP.md). */
export const CAMPAIGN_KIND = 30223;
@@ -88,6 +91,8 @@ export interface ParsedCampaign {
deadline?: number;
/** Human-readable location string. */
location?: string;
/** ISO 3166-1 alpha-2 country code parsed from a NIP-73 `i` tag. */
countryCode?: string;
/** Validated recipient list (always at least one). */
recipients: CampaignRecipient[];
/** Created-at from the event. */
@@ -114,6 +119,15 @@ function parsePositiveInt(s: string | undefined): number | undefined {
return n;
}
function getCountryCode(event: NostrEvent): string | undefined {
for (const [name, value] of event.tags) {
if (name !== 'i' || typeof value !== 'string') continue;
const code = parseCountryIdentifier(value);
if (code && /^[A-Z]{2}$/.test(code)) return code;
}
return undefined;
}
/**
* 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,12 +202,20 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
goalSats: parsePositiveInt(getTag(event, 'goal')),
deadline: parsePositiveInt(getTag(event, 'deadline')),
location: getTag(event, 'location')?.trim() || undefined,
countryCode: getCountryCode(event),
recipients,
createdAt: event.created_at,
archived,
};
}
/** Human display for a campaign's structured country, falling back to legacy location text. */
export function getCampaignCountryLabel(campaign: ParsedCampaign): string | undefined {
const country = campaign.countryCode ? COUNTRIES[campaign.countryCode] : undefined;
if (country) return `${country.flag} ${country.name}`;
return campaign.location;
}
/** Output of {@link splitDonation}: per-recipient amounts in sats. */
export interface DonationSplit {
pubkey: string;
+25 -1
View File
@@ -204,7 +204,7 @@ export const COUNTRIES: Record<string, { name: string; flag: string }> = {
};
/** Pre-sorted array of country entries for searching. */
const COUNTRY_LIST = Object.entries(COUNTRIES)
export const COUNTRY_LIST = Object.entries(COUNTRIES)
.map(([code, { name, flag }]) => ({ code, name, flag }))
.sort((a, b) => a.name.localeCompare(b.name));
@@ -250,6 +250,30 @@ export function searchCountry(query: string): CountryMatch | null {
return best ? { country: best, exact: false } : null;
}
/**
* Find multiple countries matching the query, ranked for typeahead results.
* Matches ISO code, exact name, name prefix, then name substring.
*/
export function searchCountries(query: string, limit = 8): CountryEntry[] {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const ranked = COUNTRY_LIST
.map((country) => {
const code = country.code.toLowerCase();
const name = country.name.toLowerCase();
if (code === q) return { country, rank: 0 };
if (name === q) return { country, rank: 1 };
if (name.startsWith(q)) return { country, rank: 2 };
if (name.includes(q)) return { country, rank: 3 };
return null;
})
.filter((match): match is { country: CountryEntry; rank: number } => match !== null)
.sort((a, b) => a.rank - b.rank || a.country.name.localeCompare(b.country.name));
return ranked.slice(0, limit).map(({ country }) => country);
}
/**
* Map of ISO 3166-1 alpha-2 codes to their Wikipedia article titles,
* only for countries whose common name differs from the Wikipedia title.
+4 -2
View File
@@ -55,6 +55,7 @@ import { useLayoutOptions } from '@/contexts/LayoutContext';
import {
CAMPAIGN_CATEGORY_LABELS,
encodeCampaignNaddr,
getCampaignCountryLabel,
type ParsedCampaign,
} from '@/lib/campaign';
import { satsToUSDWhole } from '@/lib/bitcoin';
@@ -232,6 +233,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
creatorMetadata?.display_name || creatorMetadata?.name || genUserName(campaign.pubkey);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const countryLabel = getCampaignCountryLabel(campaign);
const raisedSats = stats?.totalSats ?? 0;
const isCreator = user?.pubkey === campaign.pubkey;
@@ -385,10 +387,10 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
{CAMPAIGN_CATEGORY_LABELS[campaign.category]}
</span>
)}
{campaign.location && (
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5 sm:size-4" />
{campaign.location}
{countryLabel}
</span>
)}
{deadline && (
+10 -14
View File
@@ -122,14 +122,11 @@ export function CampaignsPage() {
[allCampaigns, featuredCoords],
);
// Build the spotlight pool: every campaign that has both a parseable
// location AND would make sense to feature. Featured campaigns come first
// (in their hand-picked order), then everything else, newest first.
//
// Each entry resolves a country code from the free-form `location` field
// and pulls the country's capital coordinates from `getCoordinates`. The
// globe uses these to place a heart marker; the spotlight card uses the
// full `campaign` object.
// Build the spotlight pool: every campaign that has a country and would
// make sense to feature. New events use NIP-73 `i` country tags; legacy
// campaigns can still resolve from the old free-form `location` field.
// Featured campaigns come first (in their hand-picked order), then
// everything else, newest first.
const spotlightables = useMemo(() => {
type Entry = {
key: string;
@@ -143,18 +140,17 @@ export function CampaignsPage() {
const add = (c: ParsedCampaign) => {
if (seenAtag.has(c.aTag)) return;
if (!c.location) return;
const match = searchCountry(c.location);
if (!match) return;
const coords = getCoordinates(match.country.code);
const countryCode = c.countryCode ?? (c.location ? searchCountry(c.location)?.country.code : undefined);
if (!countryCode) return;
const coords = getCoordinates(countryCode);
if (!coords) return;
// Deduplicate by country so a single popular country doesn't pile
// dozens of overlapping markers on top of each other. We keep the
// first one we see, which — given the iteration order below — means
// featured wins, then newest.
if (seenCountry.has(match.country.code)) return;
if (seenCountry.has(countryCode)) return;
seenAtag.add(c.aTag);
seenCountry.add(match.country.code);
seenCountry.add(countryCode);
out.push({ key: c.aTag, campaign: c, lat: coords.latitude, lng: coords.longitude });
};
+165 -14
View File
@@ -13,6 +13,7 @@ import {
HandHeart,
ImagePlus,
Loader2,
MapPin,
MessageCircle,
UserPlus,
X,
@@ -56,6 +57,8 @@ import {
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { COUNTRIES, searchCountries, searchCountry, type CountryEntry } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { cn } from '@/lib/utils';
interface EditTarget {
@@ -148,6 +151,11 @@ function formatGoalUsd(goalSats: number | undefined, btcPrice: number | undefine
return usd.toFixed(usd >= 100 ? 0 : 2);
}
function getExactCountryCode(query: string): string | undefined {
const match = searchCountry(query);
return match?.exact ? match.country.code : undefined;
}
function formatDateInput(unixSeconds: number | undefined): string {
if (!unixSeconds) return '';
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
@@ -173,7 +181,8 @@ export function CreateCampaignPage() {
const [category, setCategory] = useState<CampaignCategory>('human-rights');
const [goalUsd, setGoalUsd] = useState('');
const [deadline, setDeadline] = useState('');
const [location, setLocation] = useState('');
const [countryQuery, setCountryQuery] = useState('');
const [countryCode, setCountryCode] = useState('');
const [recipients, setRecipients] = useState<SearchProfile[]>([]);
const [formError, setFormError] = useState('');
const [prepopulatedEventId, setPrepopulatedEventId] = useState<string | null>(null);
@@ -263,7 +272,9 @@ export function CreateCampaignPage() {
setImageUrl(editCampaign.image ?? '');
setCategory(editCampaign.category ?? 'human-rights');
setDeadline(formatDateInput(editCampaign.deadline));
setLocation(editCampaign.location ?? '');
const editCountryCode = editCampaign.countryCode ?? getExactCountryCode(editCampaign.location ?? '') ?? '';
setCountryCode(editCountryCode);
setCountryQuery(editCountryCode ? COUNTRIES[editCountryCode]?.name ?? editCountryCode : '');
setRecipients(editCampaign.recipients.map((recipient) => makeRecipientProfile(recipient.pubkey)));
setPrepopulatedEventId(editCampaign.event.id);
}, [editCampaign, prepopulatedEventId]);
@@ -376,6 +387,8 @@ export function CreateCampaignPage() {
deadlineNum = ts;
}
const resolvedCountryCode = countryCode || getExactCountryCode(countryQuery);
let prev: NostrEvent | null = null;
if (isEditMode) {
prev = await fetchFreshEvent(nostr, {
@@ -413,7 +426,10 @@ export function CreateCampaignPage() {
if (sanitizedImage) tags.push(['image', sanitizedImage]);
if (goalNum !== undefined) tags.push(['goal', String(goalNum)]);
if (deadlineNum !== undefined) tags.push(['deadline', String(deadlineNum)]);
if (location.trim()) tags.push(['location', location.trim()]);
if (resolvedCountryCode) {
tags.push(['i', createCountryIdentifier(resolvedCountryCode)]);
tags.push(['k', 'iso3166']);
}
for (const r of parsedRecipients) {
tags.push(['p', r.pubkey]);
}
@@ -578,6 +594,29 @@ export function CreateCampaignPage() {
</p>
</FormSection>
{/* Country */}
<FormSection title="Country" requirement="Recommended" description="Helps people discover and sort campaigns by country.">
<CountrySelect
query={countryQuery}
selectedCode={countryCode}
onQueryChange={(value) => {
setCountryQuery(value);
const selectedCountry = countryCode ? COUNTRIES[countryCode] : undefined;
if (selectedCountry && value !== selectedCountry.name && value.toUpperCase() !== countryCode) {
setCountryCode('');
}
}}
onSelect={(country) => {
setCountryCode(country.code);
setCountryQuery(country.name);
}}
onClear={() => {
setCountryCode('');
setCountryQuery('');
}}
/>
</FormSection>
{/* Recipients */}
<FormSection
title="Beneficiaries"
@@ -724,15 +763,6 @@ export function CreateCampaignPage() {
onChange={(e) => setDeadline(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="campaign-location">Location (optional)</Label>
<Input
id="campaign-location"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Portland, OR"
/>
</div>
</div>
</div>
</CollapsibleFormSection>
@@ -777,7 +807,7 @@ function FormSection({
children,
}: {
title: string;
requirement: 'Required' | 'Optional';
requirement: 'Required' | 'Recommended' | 'Optional';
description?: string;
children: React.ReactNode;
}) {
@@ -791,6 +821,8 @@ function FormSection({
'rounded-full px-2 py-0.5 text-[11px] font-medium',
requirement === 'Required'
? 'bg-primary/10 text-primary'
: requirement === 'Recommended'
? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'bg-muted text-muted-foreground',
)}
>
@@ -811,7 +843,7 @@ function CollapsibleFormSection({
children,
}: {
title: string;
requirement: 'Required' | 'Optional';
requirement: 'Required' | 'Recommended' | 'Optional';
description?: string;
children: React.ReactNode;
}) {
@@ -829,6 +861,8 @@ function CollapsibleFormSection({
'rounded-full px-2 py-0.5 text-[11px] font-medium',
requirement === 'Required'
? 'bg-primary/10 text-primary'
: requirement === 'Recommended'
? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'bg-muted text-muted-foreground',
)}
>
@@ -846,6 +880,123 @@ function CollapsibleFormSection({
);
}
function CountrySelect({
query,
selectedCode,
onQueryChange,
onSelect,
onClear,
}: {
query: string;
selectedCode: string;
onQueryChange: (value: string) => void;
onSelect: (country: CountryEntry) => void;
onClear: () => void;
}) {
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedCountry = selectedCode ? COUNTRIES[selectedCode] : undefined;
const results = useMemo(() => searchCountries(query), [query]);
const showResults = open && results.length > 0;
const selectCountry = (country: CountryEntry) => {
onSelect(country);
setOpen(false);
setSelectedIndex(0);
};
return (
<div className="space-y-2">
<div className="relative">
<MapPin className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="campaign-country"
value={query}
onChange={(e) => {
onQueryChange(e.target.value);
setOpen(true);
setSelectedIndex(0);
}}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onKeyDown={(e) => {
if (!showResults) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
e.preventDefault();
selectCountry(results[selectedIndex]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}}
className="pl-9 pr-9"
placeholder="Search countries, e.g. Venezuela"
autoComplete="off"
role="combobox"
aria-expanded={showResults}
aria-controls="campaign-country-results"
/>
{(query || selectedCode) && (
<button
type="button"
onClick={onClear}
className="absolute right-2 top-1/2 rounded-full p-1 -translate-y-1/2 text-muted-foreground hover:bg-muted hover:text-foreground motion-safe:transition-colors"
aria-label="Clear country"
>
<X className="size-4" />
</button>
)}
{showResults && (
<div
id="campaign-country-results"
role="listbox"
className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg"
>
{results.map((country, index) => (
<button
key={country.code}
type="button"
role="option"
aria-selected={index === selectedIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectCountry(country)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left motion-safe:transition-colors',
index === selectedIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-secondary text-lg leading-none" role="img" aria-label={`Flag of ${country.name}`}>
{country.flag}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{country.name}</span>
<span className="block text-xs text-muted-foreground">{country.code}</span>
</span>
</button>
))}
</div>
)}
</div>
{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>
);
}
function CoverPicker({
url,
isUploading,