From ba2c541c316c8213b77749ffdca5905499dcce8e Mon Sep 17 00:00:00 2001 From: lemon Date: Sun, 17 May 2026 22:38:25 -0700 Subject: [PATCH] Add country tags to campaigns --- NIP.md | 9 +- src/components/CampaignCard.tsx | 6 +- src/components/HeroCampaignSpotlight.tsx | 7 +- src/hooks/useCampaigns.ts | 8 +- src/lib/campaign.ts | 22 +++ src/lib/countries.ts | 26 +++- src/pages/CampaignDetailPage.tsx | 6 +- src/pages/CampaignsPage.tsx | 24 ++- src/pages/CreateCampaignPage.tsx | 179 +++++++++++++++++++++-- 9 files changed, 246 insertions(+), 41 deletions(-) diff --git a/NIP.md b/NIP.md index dca4af11..8902b22b 100644 --- a/NIP.md +++ b/NIP.md @@ -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", "", "wss://relay.example", "2"], ["p", "", "wss://relay.example", "1"], ["p", ""], @@ -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:` 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. | diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx index ff23c6bd..9cb18a95 100644 --- a/src/components/CampaignCard.tsx +++ b/src/components/CampaignCard.tsx @@ -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'} )} - {campaign.location && ( + {countryLabel && ( - {campaign.location} + {countryLabel} )} {deadline && ( diff --git a/src/components/HeroCampaignSpotlight.tsx b/src/components/HeroCampaignSpotlight.tsx index da49fc37..2e9e494c 100644 --- a/src/components/HeroCampaignSpotlight.tsx +++ b/src/components/HeroCampaignSpotlight.tsx @@ -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 (
{authorName} - {campaign.location && ( + {countryLabel && ( - {campaign.location} + {countryLabel} )} { 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; diff --git a/src/lib/campaign.ts b/src/lib/campaign.ts index 5dcc49c9..aaa9189a 100644 --- a/src/lib/campaign.ts +++ b/src/lib/campaign.ts @@ -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; diff --git a/src/lib/countries.ts b/src/lib/countries.ts index 98997b61..d7cef498 100644 --- a/src/lib/countries.ts +++ b/src/lib/countries.ts @@ -204,7 +204,7 @@ export const COUNTRIES: Record = { }; /** 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. diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 2c984ee6..38108b4f 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -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]} )} - {campaign.location && ( + {countryLabel && ( - {campaign.location} + {countryLabel} )} {deadline && ( diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx index 77407444..32fbde0b 100644 --- a/src/pages/CampaignsPage.tsx +++ b/src/pages/CampaignsPage.tsx @@ -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 }); }; diff --git a/src/pages/CreateCampaignPage.tsx b/src/pages/CreateCampaignPage.tsx index 4dc9ece7..354162df 100644 --- a/src/pages/CreateCampaignPage.tsx +++ b/src/pages/CreateCampaignPage.tsx @@ -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('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([]); const [formError, setFormError] = useState(''); const [prepopulatedEventId, setPrepopulatedEventId] = useState(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() {

+ {/* Country */} + + { + 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(''); + }} + /> + + {/* Recipients */} setDeadline(e.target.value)} />
-
- - setLocation(e.target.value)} - placeholder="Portland, OR" - /> -
@@ -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 ( +
+
+ + { + 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) && ( + + )} + + {showResults && ( +
+ {results.map((country, index) => ( + + ))} +
+ )} +
+ + {selectedCountry ? ( +

+ Publishes i: iso3166:{selectedCode} for country sorting. +

+ ) : ( +

+ Start typing a country name, then choose one from the list. +

+ )} +
+ ); +} + function CoverPicker({ url, isUploading,