Files
eranos/src/pages/CreateCampaignPage.tsx
T
2026-05-27 23:57:35 -07:00

1188 lines
47 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useTranslation, Trans } from 'react-i18next';
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import {
AlertTriangle,
ArrowLeft,
HandHeart,
HelpCircle,
Loader2,
MapPin,
Wallet,
X,
} from 'lucide-react';
import { CoverImageField } from '@/components/CoverImageField';
import { CountryFlag } from '@/components/CountryFlag';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { LoginArea } from '@/components/auth/LoginArea';
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 { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppContext } from '@/hooks/useAppContext';
import { useAuthor } from '@/hooks/useAuthor';
import { useCampaign } from '@/hooks/useCampaign';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useHdWallet } from '@/hooks/useHdWallet';
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import {
CAMPAIGN_KIND,
encodeCampaignNaddr,
parseCampaign,
parseCampaignWallet,
slugifyCampaignIdentifier,
} from '@/lib/campaign';
import { getTodayDateInput } from '@/lib/dateInput';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { withAgoraTag } from '@/lib/agoraNoteTags';
import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries';
import { getEditableContentTags, parseContentTagInput } from '@/lib/contentTags';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { cn } from '@/lib/utils';
interface EditTarget {
pubkey: string;
identifier: string;
relays?: string[];
}
function getEditTarget(value: string | null): EditTarget | null {
if (!value) return null;
try {
const decoded = nip19.decode(value);
if (decoded.type !== 'naddr' || decoded.data.kind !== CAMPAIGN_KIND) return null;
return {
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier,
...(decoded.data.relays ? { relays: decoded.data.relays } : {}),
};
} catch {
return null;
}
}
function formatDateInput(unixSeconds: number | undefined): string {
if (!unixSeconds) return '';
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
/**
* Build a NIP-92 `imeta` tag from a Blossom upload's NIP-94 tag array.
*
* NIP-94 returns pairs like `[["url", "<url>"], ["x", "<sha256>"], ["m", "image/jpeg"], ...]`.
* NIP-92 packs the same key/value pairs into a single space-separated tag:
* `["imeta", "url <url>", "x <sha256>", "m image/jpeg", ...]`.
*
* The `url` entry MUST come first (NIP-92 convention). Other keys are
* emitted in their original order.
*/
function buildImetaFromNip94(nip94Tags: string[][]): string[] {
const result: string[] = ['imeta'];
// url first
const urlPair = nip94Tags.find((t) => t[0] === 'url');
if (urlPair && urlPair[1]) result.push(`url ${urlPair[1]}`);
for (const [key, value] of nip94Tags) {
if (key === 'url') continue;
if (typeof key !== 'string' || typeof value !== 'string') continue;
if (key.includes(' ')) continue;
result.push(`${key} ${value}`);
}
return result;
}
export function CreateCampaignPage() {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const hdWallet = useHdWallet();
const hdWalletAvailable = hdWallet.availability.status === 'available';
const silentPaymentSupported = hdWalletAvailable && !!hdWallet.silentPaymentAddress;
const userAuthor = useAuthor(user?.pubkey);
const userMetadata = userAuthor.data?.metadata;
const userDisplayName = user
? (userMetadata?.name ?? userMetadata?.display_name ?? genUserName(user.pubkey))
: '';
const [title, setTitle] = useState('');
const [story, setStory] = useState('');
const [bannerUrl, setBannerUrl] = useState('');
/** NIP-94-format tag pairs from the most recent banner upload, used to build the NIP-92 imeta tag on publish. */
const [bannerNip94Tags, setBannerNip94Tags] = useState<string[][] | null>(null);
const [coverUploading, setCoverUploading] = useState(false);
/**
* Wallet form state.
*
* - {@link walletSource} picks the top-level source: `'mine'` (the
* user's HD wallet, only selectable when nsec is available) or
* `'custom'` (paste any mainnet bech32(m) endpoint).
* - {@link mineAccept} picks which donation types the HD-wallet
* campaign accepts:
* - `'all'` — publishes both a fresh on-chain address and the
* static silent-payment code.
* - `'public'` — on-chain only (advances the HD receive cursor).
* - `'private'` — silent-payment only.
* - {@link customOnchain} / {@link customSp} are the typed values
* when {@link walletSource} is `'custom'`. At least one of them
* must parse to a valid endpoint of the matching mode.
*
* Without nsec access the dropdown is hidden entirely; the user
* sees only the two custom inputs.
*
* Both fields are seeded from the synchronously-available HD-wallet
* availability on first render so the dropdown opens already showing
* the user's Agora wallet (and the matching `mineAccept` choice)
* instead of the empty "Choose a wallet" placeholder. The effect
* below still re-runs on availability changes for the edge case
* where the hook resolves a tick later, but the common nsec-login
* path no longer flickers through `'custom'` on mount.
*/
const [walletSource, setWalletSource] = useState<'mine' | 'custom'>(
() => (hdWalletAvailable ? 'mine' : 'custom'),
);
const [mineAccept, setMineAccept] = useState<'all' | 'public' | 'private'>(
() => (hdWalletAvailable && !silentPaymentSupported ? 'public' : 'all'),
);
const [customOnchain, setCustomOnchain] = useState('');
const [customSp, setCustomSp] = useState('');
const [goalUsd, setGoalUsd] = useState('');
const [deadline, setDeadline] = useState('');
const [countryQuery, setCountryQuery] = useState('');
const [countryCode, setCountryCode] = useState('');
const [tagInput, setTagInput] = useState('');
const [organizationATag, setOrganizationATag] = useState('');
const [formError, setFormError] = useState('');
const [prepopulatedEventId, setPrepopulatedEventId] = useState<string | null>(null);
const editNaddr = searchParams.get('edit');
const editTarget = useMemo(() => getEditTarget(editNaddr), [editNaddr]);
const isEditMode = !!editNaddr;
// ── Organization context (implicit) ────────────────────────────────────
const orgParam = searchParams.get('org');
const orgFromParam = useMemo(() => decodeOrganizationParam(orgParam), [orgParam]);
const { data: manageableOrgs, isLoading: manageableOrgsLoading } = useManageableOrganizations();
const authorizedOrgFromParam = useMemo(() => {
if (!orgFromParam || !manageableOrgs) return null;
return manageableOrgs.find((entry) => entry.community.aTag === orgFromParam.aTag) ?? null;
}, [orgFromParam, manageableOrgs]);
const authorizedOrgForAttachedATag = useMemo(() => {
if (!organizationATag || !manageableOrgs) return null;
return manageableOrgs.find((entry) => entry.community.aTag === organizationATag) ?? null;
}, [manageableOrgs, organizationATag]);
useEffect(() => {
if (isEditMode) return;
setOrganizationATag(authorizedOrgFromParam?.community.aTag ?? '');
}, [isEditMode, authorizedOrgFromParam]);
// When the HD wallet becomes available on a fresh campaign, default
// the source to "My wallet". Skipped in edit mode (we always start
// in 'custom' with the existing values pre-filled — see the
// edit-prepopulation effect below) and once the defaults have been
// applied, so re-renders that re-derive `hdWalletAvailable` don't
// override an explicit user choice.
const [walletDefaultsApplied, setWalletDefaultsApplied] = useState(false);
useEffect(() => {
if (isEditMode || walletDefaultsApplied) return;
if (!hdWalletAvailable) return;
setWalletSource('mine');
setMineAccept(silentPaymentSupported ? 'all' : 'public');
setWalletDefaultsApplied(true);
}, [isEditMode, walletDefaultsApplied, hdWalletAvailable, silentPaymentSupported]);
// Without nsec access, the dropdown is hidden and `walletSource` is
// pinned to `'custom'`. Guard against a stale `'mine'` from a prior
// logged-in session if the user signs out without unmounting the
// page.
useEffect(() => {
if (!hdWalletAvailable && walletSource !== 'custom') {
setWalletSource('custom');
}
}, [hdWalletAvailable, walletSource]);
// If silent-payment support disappears (e.g., login switched to a
// login type that can't derive SP), coerce a stale 'all' / 'private'
// selection to 'public' so the submit handler never has to
// apologize for a UI choice the user can't actually make.
useEffect(() => {
if (!silentPaymentSupported && mineAccept !== 'public') {
setMineAccept('public');
}
}, [silentPaymentSupported, mineAccept]);
const editCampaignQuery = useCampaign({
pubkey: editTarget?.pubkey ?? '',
identifier: editTarget?.identifier ?? '',
...(editTarget?.relays ? { relays: editTarget.relays } : {}),
});
const editCampaign = isEditMode ? editCampaignQuery.data : null;
// The slug is protocol plumbing: derive it from the title instead of asking
// fundraisers to understand Nostr d-tags.
const derivedIdentifier = useMemo(() => slugifyCampaignIdentifier(title), [title]);
const activeIdentifier = editCampaign?.identifier ?? derivedIdentifier;
const minDeadline = useMemo(() => getTodayDateInput(), []);
// Live-parsed custom inputs, used to drive disclaimers and inline
// validation. Empty strings parse to `null` (no inline error).
const parsedCustomOnchain = useMemo(
() => (customOnchain.trim() ? parseCampaignWallet(customOnchain) : null),
[customOnchain],
);
const parsedCustomSp = useMemo(
() => (customSp.trim() ? parseCampaignWallet(customSp) : null),
[customSp],
);
useSeoMeta({
title: `${isEditMode ? t('campaignsCreate.seoTitleEdit') : t('campaignsCreate.seoTitleCreate')} | ${config.appName}`,
description: isEditMode
? t('campaignsCreate.seoDescriptionEdit', { appName: config.appName })
: t('campaignsCreate.seoDescriptionCreate', { appName: config.appName }),
});
useEffect(() => {
if (!editCampaign || prepopulatedEventId === editCampaign.event.id) return;
setTitle(editCampaign.title);
setStory(editCampaign.story);
setBannerUrl(editCampaign.banner ?? '');
// We don't have NIP-94 tags for an existing event — the imeta is
// already on the event. We'll re-emit it from the original event
// tags below if the URL is unchanged.
setBannerNip94Tags(null);
// Edit mode always starts in 'custom' with the existing endpoints
// pre-filled. We don't try to auto-detect whether the stored `w`
// tags came from the user's HD wallet — switching wallets is an
// explicit choice the user must make, and the cursor must not be
// burned on a no-op edit.
setWalletSource('custom');
setCustomOnchain(editCampaign.wallets.onchain?.value ?? '');
setCustomSp(editCampaign.wallets.sp?.value ?? '');
setWalletDefaultsApplied(true);
setGoalUsd(editCampaign.goalUsd !== undefined ? String(editCampaign.goalUsd) : '');
setDeadline(formatDateInput(editCampaign.deadline));
const editCountryCode = editCampaign.countryCode ?? '';
setCountryCode(editCountryCode);
setCountryQuery(editCountryCode ? (getCountryInfo(editCountryCode)?.subdivisionName ?? getCountryInfo(editCountryCode)?.name ?? editCountryCode) : '');
setTagInput(getEditableContentTags(editCampaign.event.tags).join(', '));
const existingOrgATag = editCampaign.event.tags.find(
([n, v]) => n === 'A' && typeof v === 'string' && v.startsWith('34550:'),
)?.[1] ?? '';
setOrganizationATag(existingOrgATag);
setPrepopulatedEventId(editCampaign.event.id);
}, [editCampaign, prepopulatedEventId]);
const submitMutation = useMutation({
mutationFn: async () => {
if (!user) throw new Error(t('campaignsCreate.errorLoginRequired'));
if (isEditMode && !editCampaign) throw new Error(t('campaignsCreate.errorEditLoadFailed'));
if (editCampaign && editCampaign.pubkey !== user.pubkey) {
throw new Error(t('campaignsCreate.errorEditNotOwner'));
}
const trimmedTitle = title.trim();
const slug = activeIdentifier;
if (!trimmedTitle) throw new Error(t('campaignsCreate.errorTitleRequired'));
if (!slug) throw new Error(t('campaignsCreate.errorTitleInvalid'));
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
throw new Error(t('campaignsCreate.errorSlugInvalid'));
}
// Resolve the campaign's `w` endpoints.
//
// - 'mine' → derive from the user's HD wallet. {@link mineAccept}
// selects which modes are published. The on-chain receive
// index is advanced later (just before the `w` tag is
// appended) so a validation failure between here and there
// doesn't burn an index.
// - 'custom' → validate the typed values up-front. At least one
// must parse to its expected mode.
//
// `willUseHdOnchain` and `spWallet` are the resolved targets;
// `customOnchainWallet` is populated when 'custom' is selected
// and the bc1 field parses.
let customOnchainWallet = null as ReturnType<typeof parseCampaignWallet>;
let customSpWallet = null as ReturnType<typeof parseCampaignWallet>;
let willUseHdOnchain = false;
let spWallet = null as ReturnType<typeof parseCampaignWallet>;
if (walletSource === 'mine') {
if (!hdWalletAvailable) {
throw new Error(t('campaignsCreate.errorHdUnavailable'));
}
const wantsOnchain = mineAccept === 'all' || mineAccept === 'public';
const wantsSp = mineAccept === 'all' || mineAccept === 'private';
if (wantsSp && !silentPaymentSupported) {
throw new Error(t('campaignsCreate.errorSpUnavailable'));
}
willUseHdOnchain = wantsOnchain;
if (wantsSp && hdWallet.silentPaymentAddress) {
spWallet = parseCampaignWallet(hdWallet.silentPaymentAddress.address);
}
} else {
// 'custom'
const customOnchainTrimmed = customOnchain.trim();
const customSpTrimmed = customSp.trim();
if (customOnchainTrimmed) {
customOnchainWallet = parseCampaignWallet(customOnchainTrimmed);
if (!customOnchainWallet || customOnchainWallet.mode !== 'onchain') {
throw new Error(t('campaignsCreate.errorOnchainInvalid'));
}
}
if (customSpTrimmed) {
customSpWallet = parseCampaignWallet(customSpTrimmed);
if (!customSpWallet || customSpWallet.mode !== 'sp') {
throw new Error(t('campaignsCreate.errorSpInvalid'));
}
}
spWallet = customSpWallet;
}
// At least one endpoint must resolve.
if (!willUseHdOnchain && !customOnchainWallet && !spWallet) {
throw new Error(t('campaignsCreate.errorWalletRequired'));
}
// Goal — integer USD (no unit, no currency conversion).
let goalNum: number | undefined;
const trimmedGoal = goalUsd.replace(/[, $]/g, '').trim();
if (trimmedGoal) {
const n = Number(trimmedGoal);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
throw new Error(t('campaignsCreate.errorGoalInvalid'));
}
goalNum = n;
}
let deadlineNum: number | undefined;
if (deadline.trim()) {
if (deadline < minDeadline) {
throw new Error(t('campaignsCreate.errorDeadlinePast'));
}
const ts = Math.floor(new Date(deadline).getTime() / 1000);
if (!Number.isFinite(ts) || ts <= 0) {
throw new Error(t('campaignsCreate.errorDeadlineInvalid'));
}
deadlineNum = ts;
}
const resolvedCountryCode = countryCode;
const contentTags = parseContentTagInput(tagInput);
let prev: NostrEvent | null = null;
if (isEditMode) {
prev = await fetchFreshEvent(nostr, {
kinds: [CAMPAIGN_KIND],
authors: [user.pubkey],
'#d': [slug],
});
if (!prev || !parseCampaign(prev)) {
throw new Error(t('campaignsCreate.errorEditLatestMissing'));
}
} else {
// d-tag collision guard. Block silent overwrite of an existing campaign
// by the same author — even with the same author, we want explicit edit
// flows, not "create with the same slug".
const existing = await nostr.query([
{ kinds: [CAMPAIGN_KIND], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
throw new Error(t('campaignsCreate.errorSlugCollision', { slug }));
}
}
// Validate banner URL (must be https).
const trimmedBanner = bannerUrl.trim();
const sanitizedBanner = trimmedBanner ? sanitizeUrl(trimmedBanner) : undefined;
if (trimmedBanner && !sanitizedBanner) {
throw new Error(t('campaignsCreate.errorBannerInvalid'));
}
const tags: string[][] = [
['d', slug],
['title', trimmedTitle],
];
if (sanitizedBanner) {
tags.push(['banner', sanitizedBanner]);
// NIP-92 imeta pairs with the banner. Two sources, in priority order:
// 1. A fresh upload during this session — convert the NIP-94 tag
// array from the uploader into the NIP-92 space-separated form.
// 2. The existing event's imeta tag — re-emit it verbatim when the
// URL hasn't changed during an edit.
const imeta = (() => {
if (bannerNip94Tags) {
const url = bannerNip94Tags.find((t) => t[0] === 'url')?.[1];
if (url === sanitizedBanner) {
return buildImetaFromNip94(bannerNip94Tags);
}
}
if (isEditMode && editCampaign?.banner === sanitizedBanner) {
const existing = editCampaign.event.tags.find(([n]) => n === 'imeta');
if (existing) return existing;
}
return null;
})();
if (imeta) tags.push(imeta);
}
tags.push(['alt', t('campaignsCreate.altText', { title: trimmedTitle })]);
// Last step before the `w` tags: advance the HD wallet cursor
// if the user chose 'mine' and the selected accept mode includes
// on-chain. Deliberately the *last* mutation we do before
// publishing so a validation failure earlier in this function
// doesn't burn an index.
let onchainWallet = customOnchainWallet;
if (!onchainWallet && willUseHdOnchain) {
const next = hdWallet.nextReceiveAddress();
if (!next) {
throw new Error(t('campaignsCreate.errorHdDeriveFailed'));
}
const parsed = parseCampaignWallet(next.address);
if (!parsed || parsed.mode !== 'onchain') {
throw new Error(t('campaignsCreate.errorHdDeriveInvalid'));
}
onchainWallet = parsed;
}
if (!onchainWallet && !spWallet) {
// Defense in depth — the earlier guard already covers this,
// but the type narrower can't see across the cursor advance.
throw new Error(t('campaignsCreate.errorWalletRequiredFallback'));
}
if (onchainWallet) tags.push(['w', onchainWallet.value]);
if (spWallet) tags.push(['w', spWallet.value]);
if (goalNum !== undefined) tags.push(['goal', String(goalNum)]);
if (deadlineNum !== undefined) tags.push(['deadline', String(deadlineNum)]);
if (resolvedCountryCode) {
tags.push(['i', createCountryIdentifier(resolvedCountryCode)]);
tags.push(['k', 'iso3166']);
}
for (const tag of contentTags) tags.push(['t', tag]);
// Organization association (NIP-22 root-scope convention): an
// uppercase `A` tag points at the NIP-72 community definition so
// the campaign surfaces as official activity on that org's page.
const publishOrganizationATag = isEditMode
? authorizedOrgForAttachedATag?.community.aTag ?? ''
: organizationATag;
if (publishOrganizationATag) {
tags.push(...createOrganizationAssociationTags(publishOrganizationATag));
}
const published = await publishEvent({
kind: CAMPAIGN_KIND,
content: story,
tags: withAgoraTag(tags),
prev: prev ?? undefined,
});
const parsed = parseCampaign(published);
if (!parsed) {
throw new Error(t('campaignsCreate.errorPublishedInvalid'));
}
return parsed;
},
onSuccess: (campaign) => {
void queryClient.invalidateQueries({ queryKey: ['campaign', campaign.pubkey, campaign.identifier] });
// Refresh the campaign list queries used by the home/discover/profile
// views so a newly launched (or just-edited) campaign shows up without
// a manual reload.
void queryClient.invalidateQueries({ queryKey: ['campaigns'] });
void queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
const publishOrganizationATag = isEditMode
? authorizedOrgForAttachedATag?.community.aTag ?? ''
: organizationATag;
if (publishOrganizationATag) {
void queryClient.invalidateQueries({ queryKey: ['organization-activity', publishOrganizationATag] });
}
// Campaigns carrying a country code surface on that country's feed.
if (campaign.countryCode) {
void queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', campaign.countryCode] });
void queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', campaign.countryCode] });
}
// Campaigns (kind 33863) also surface in the home Agora activity
// feed regardless of country code.
void queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
void queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
toast({
title: isEditMode ? t('campaignsCreate.successEdit') : t('campaignsCreate.successCreate'),
description: isEditMode ? t('campaignsCreate.successEditDesc') : t('campaignsCreate.successCreateDesc'),
});
navigate(`/${encodeCampaignNaddr(campaign)}`);
},
onError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
setFormError(msg);
toast({
title: isEditMode ? t('campaignsCreate.errorTitleEdit') : t('campaignsCreate.errorTitleCreate'),
description: msg,
variant: 'destructive',
});
},
});
if (!user) {
return (
<main className="min-h-screen pb-16">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<Card>
<CardContent className="py-12 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<HandHeart className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">{t('campaignsCreate.loginGateTitle')}</h2>
<p className="text-muted-foreground text-sm">
{t('campaignsCreate.loginGateBody')}
</p>
</div>
<LoginArea className="max-w-60" />
</CardContent>
</Card>
</div>
</main>
);
}
if (isEditMode && !editTarget) {
return (
<main className="min-h-screen pb-16">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<Card>
<CardContent className="py-12 px-8 text-center space-y-4">
<AlertTriangle className="size-10 text-muted-foreground/60 mx-auto" />
<h2 className="text-xl font-semibold">{t('campaignsCreate.invalidEditTitle')}</h2>
<p className="text-muted-foreground">
{t('campaignsCreate.invalidEditBody')}
</p>
<Button type="button" onClick={() => navigate('/campaigns/new')}>
{t('campaignsCreate.startNewCampaign')}
</Button>
</CardContent>
</Card>
</div>
</main>
);
}
if (isEditMode && editCampaignQuery.isLoading) {
return (
<main className="min-h-screen pb-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10">
<Card>
<CardContent className="py-12 px-8 text-center space-y-3">
<Loader2 className="size-8 animate-spin text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground">{t('campaignsCreate.loadingCampaign')}</p>
</CardContent>
</Card>
</div>
</main>
);
}
if (isEditMode && (!editCampaign || editCampaign.pubkey !== user.pubkey)) {
return (
<main className="min-h-screen pb-16">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<Card>
<CardContent className="py-12 px-8 text-center space-y-4">
<AlertTriangle className="size-10 text-muted-foreground/60 mx-auto" />
<h2 className="text-xl font-semibold">{t('campaignsCreate.cannotEditTitle')}</h2>
<p className="text-muted-foreground">
{t('campaignsCreate.cannotEditBody')}
</p>
<Button type="button" onClick={() => navigate(-1)}>
{t('common.goBack')}
</Button>
</CardContent>
</Card>
</div>
</main>
);
}
return (
<main className="min-h-screen pb-16">
<form
className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10 space-y-5"
onSubmit={(e) => {
e.preventDefault();
setFormError('');
submitMutation.mutate();
}}
>
<div>
<div className="flex items-center gap-2 -ml-2">
<button
type="button"
onClick={() => navigate(-1)}
className="p-2 rounded-full hover:bg-secondary motion-safe:transition-colors text-muted-foreground hover:text-foreground"
aria-label={t('common.goBack')}
>
<ArrowLeft className="size-5 rtl:rotate-180" />
</button>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
{isEditMode ? t('campaignsCreate.headingEdit') : t('campaignsCreate.headingCreate')}
</h1>
</div>
<OrganizationContextChip
aTag={isEditMode && organizationATag && !authorizedOrgForAttachedATag && !manageableOrgsLoading ? '' : organizationATag}
authorizedOrg={isEditMode ? authorizedOrgForAttachedATag : authorizedOrgFromParam}
param={orgParam}
paramDecoded={orgFromParam}
manageableLoading={manageableOrgsLoading}
isEditMode={isEditMode}
/>
</div>
<div className="rounded-2xl bg-card/50 p-2">
{/* Title */}
<FormSection title={t('forms.title')} requirement="Required">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('campaignsCreate.titlePlaceholder')}
maxLength={200}
required
/>
</FormSection>
{/* Wallet (required) */}
<FormSection title={t('campaignsCreate.wallet')} requirement="Required">
<WalletPicker
hdWalletAvailable={hdWalletAvailable}
silentPaymentSupported={silentPaymentSupported}
displayName={userDisplayName}
picture={userMetadata?.picture}
walletSource={walletSource}
onWalletSourceChange={setWalletSource}
mineAccept={mineAccept}
onMineAcceptChange={setMineAccept}
customOnchain={customOnchain}
onCustomOnchainChange={setCustomOnchain}
parsedCustomOnchain={parsedCustomOnchain}
customSp={customSp}
onCustomSpChange={setCustomSp}
parsedCustomSp={parsedCustomSp}
/>
</FormSection>
{/* Country */}
<FormSection title={t('forms.country')} requirement="Recommended">
<CountrySelect
query={countryQuery}
selectedCode={countryCode}
onQueryChange={(value) => {
setCountryQuery(value);
const selectedCountry = countryCode ? getCountryInfo(countryCode) : undefined;
const selectedName = selectedCountry?.subdivisionName ?? selectedCountry?.name;
if (selectedCountry && value !== selectedName && value.toUpperCase() !== countryCode) {
setCountryCode('');
}
}}
onSelect={(country) => {
setCountryCode(country.code);
setCountryQuery(country.name);
}}
onClear={() => {
setCountryCode('');
setCountryQuery('');
}}
/>
</FormSection>
{/* Tags */}
<FormSection title={t('forms.tags')} requirement="Optional">
<Input
id="campaign-tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder={t('campaignsCreate.tagsPlaceholder')}
/>
</FormSection>
{/* Banner image */}
<FormSection title={t('campaignsCreate.banner')} requirement="Recommended">
<CoverImageField
value={bannerUrl}
onChange={(url) => {
setBannerUrl(url);
// Discard stale NIP-94 tags whenever the URL changes — a manual
// paste or template pick won't carry matching metadata.
setBannerNip94Tags(null);
}}
onUploadingChange={setCoverUploading}
onUploadComplete={(nip94Tags) => {
// Capture the NIP-94 tag array so we can convert it into a
// NIP-92 imeta tag at publish time.
setBannerNip94Tags(nip94Tags);
}}
/>
</FormSection>
{/* Story */}
<FormSection title={t('campaignsCreate.story')} requirement="Recommended">
<Textarea
id="campaign-story"
value={story}
onChange={(e) => setStory(e.target.value)}
placeholder={t('campaignsCreate.storyPlaceholder')}
rows={7}
className="font-mono text-base md:text-sm"
/>
</FormSection>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{/* Goal — integer USD */}
<FormSection
title={(
<span className="inline-flex items-center gap-1.5">
{t('campaignsCreate.goal')}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={t('campaignsCreate.goalNote')}
>
<HelpCircle className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{t('campaignsCreate.goalNote')}
</TooltipContent>
</Tooltip>
</span>
)}
requirement="Optional"
>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="campaign-goal"
type="text"
inputMode="numeric"
placeholder={t('campaignsCreate.goalPlaceholder')}
value={goalUsd}
onChange={(e) => setGoalUsd(e.target.value)}
className="pl-7 pr-14"
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
USD
</span>
</div>
</FormSection>
{/* Deadline */}
<FormSection title={t('campaignsCreate.deadline')} requirement="Optional">
<Input
id="campaign-deadline"
type="date"
min={minDeadline}
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
className="[color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
/>
</FormSection>
</div>
</div>
{formError && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<div className="pt-1">
<Button type="submit" disabled={submitMutation.isPending || coverUploading} className="w-full">
{submitMutation.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{isEditMode ? t('campaignsCreate.updating') : t('forms.publishing')}
</>
) : coverUploading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{t('campaignsCreate.uploadingBanner')}
</>
) : (
<>
<HandHeart className="size-4 mr-2" />
{isEditMode ? t('campaignsCreate.submitEdit') : t('campaignsCreate.submitCreate')}
</>
)}
</Button>
</div>
</form>
</main>
);
}
// ─── Layout helpers ──────────────────────────────────────────────────────────
/**
* Wallet picker for the campaign form.
*
* For nsec users we render two dropdowns:
*
* 1. **Source** — "My wallet" (default) or "Custom".
* 2. **Accept** — only shown when source is "My wallet". Picks which
* donation types the HD-wallet campaign accepts: all, public only,
* or private only.
*
* For users without nsec access (extension / bunker logins) the
* dropdowns are skipped entirely and we show the two custom inputs
* directly — that's the only path to a wallet endpoint for those
* logins.
*
* When source is "Custom" the user can fill the on-chain address, the
* silent-payment code, or both. At least one must parse to a valid
* endpoint of its mode.
*/
function WalletPicker({
hdWalletAvailable,
silentPaymentSupported,
displayName,
picture,
walletSource,
onWalletSourceChange,
mineAccept,
onMineAcceptChange,
customOnchain,
onCustomOnchainChange,
parsedCustomOnchain,
customSp,
onCustomSpChange,
parsedCustomSp,
}: {
hdWalletAvailable: boolean;
silentPaymentSupported: boolean;
displayName: string;
picture?: string;
walletSource: 'mine' | 'custom';
onWalletSourceChange: (value: 'mine' | 'custom') => void;
mineAccept: 'all' | 'public' | 'private';
onMineAcceptChange: (value: 'all' | 'public' | 'private') => void;
customOnchain: string;
onCustomOnchainChange: (value: string) => void;
parsedCustomOnchain: ReturnType<typeof parseCampaignWallet>;
customSp: string;
onCustomSpChange: (value: string) => void;
parsedCustomSp: ReturnType<typeof parseCampaignWallet>;
}) {
const { t } = useTranslation();
const initial = displayName.charAt(0).toUpperCase() || '?';
const myWalletLabel = displayName
? t('campaignsCreate.myWalletLabel', { name: displayName })
: t('campaignsCreate.myWalletDefault');
return (
<div className="space-y-3">
{hdWalletAvailable ? (
<>
<Select value={walletSource} onValueChange={(v) => onWalletSourceChange(v as 'mine' | 'custom')}>
<SelectTrigger className="h-12">
<SelectValue placeholder={t('campaignsCreate.walletChoose')}>
{walletSource === 'custom' ? (
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Wallet className="size-3.5" />
</span>
<span className="text-sm">{t('campaignsCreate.walletCustom')}</span>
</span>
) : (
<span className="inline-flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="truncate text-sm">{myWalletLabel}</span>
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="mine">
<span className="inline-flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="text-sm">{myWalletLabel}</span>
</span>
</SelectItem>
<SelectItem value="custom">
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Wallet className="size-3.5" />
</span>
<span className="text-sm">{t('campaignsCreate.walletCustom')}</span>
</span>
</SelectItem>
</SelectContent>
</Select>
{walletSource === 'mine' && (
<Select
value={mineAccept}
onValueChange={(v) => onMineAcceptChange(v as 'all' | 'public' | 'private')}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" disabled={!silentPaymentSupported}>
{t('campaignsCreate.acceptAll')}
</SelectItem>
<SelectItem value="public">{t('campaignsCreate.acceptPublic')}</SelectItem>
<SelectItem value="private" disabled={!silentPaymentSupported}>
{t('campaignsCreate.acceptPrivate')}
</SelectItem>
</SelectContent>
</Select>
)}
</>
) : (
<p className="text-xs text-muted-foreground">
{t('campaignsCreate.customWalletIntro')}
</p>
)}
{walletSource === 'custom' && (
<div className="space-y-3 pt-1">
<CustomWalletInput
id="campaign-wallet-onchain"
label={t('campaignsCreate.bitcoinAddress')}
placeholder={t('campaignsCreate.bitcoinAddressPlaceholder')}
value={customOnchain}
onChange={onCustomOnchainChange}
parsed={parsedCustomOnchain}
expectedMode="onchain"
/>
<CustomWalletInput
id="campaign-wallet-sp"
label={t('campaignsCreate.silentPaymentCode')}
placeholder={t('campaignsCreate.silentPaymentCodePlaceholder')}
value={customSp}
onChange={onCustomSpChange}
parsed={parsedCustomSp}
expectedMode="sp"
/>
</div>
)}
</div>
);
}
/**
* Single labeled custom-wallet input. The inline error fires only when
* a non-empty value either fails to parse OR parses to a mode that
* doesn't match {@link expectedMode} (e.g., an `sp1…` typed into the
* on-chain field).
*/
function CustomWalletInput({
id,
label,
placeholder,
value,
onChange,
parsed,
expectedMode,
}: {
id: string;
label: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
parsed: ReturnType<typeof parseCampaignWallet>;
expectedMode: 'onchain' | 'sp';
}) {
const { t } = useTranslation();
const trimmed = value.trim();
const hasError = trimmed.length > 0 && (!parsed || parsed.mode !== expectedMode);
const errorMessage =
expectedMode === 'onchain'
? t('campaignsCreate.onchainInvalid')
: t('campaignsCreate.spInvalid');
return (
<div className="space-y-1">
<label htmlFor={id} className="text-xs font-medium text-muted-foreground">
{label}
</label>
<div className="relative">
<Wallet className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={id}
value={value}
onChange={(e) => onChange(e.target.value.trim())}
placeholder={placeholder}
className={cn('pl-9 font-mono text-xs', hasError && 'border-destructive focus-visible:ring-destructive')}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
aria-invalid={hasError}
/>
</div>
{hasError && <p className="text-xs text-destructive">{errorMessage}</p>}
</div>
);
}
function CountrySelect({
query,
selectedCode,
onQueryChange,
onSelect,
onClear,
}: {
query: string;
selectedCode: string;
onQueryChange: (value: string) => void;
onSelect: (country: CountryEntry) => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedCountry = selectedCode ? getCountryInfo(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="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={t('forms.countrySearchPlaceholder')}
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={t('campaignsCreate.countryClearAria')}
>
<X className="size-4" />
</button>
)}
{showResults && (
<div
id="campaign-country-results"
role="listbox"
className="absolute z-20 mt-2 max-h-[200px] w-full overflow-y-auto rounded-xl border border-border bg-popover py-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 cursor-pointer items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-secondary/60',
index === selectedIndex && 'bg-secondary/60',
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary leading-none">
<CountryFlag
code={country.code}
emoji={country.flag}
label={t('campaignsCreate.flagOfAria', { name: country.name })}
className="text-lg"
/>
</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">
<Trans
i18nKey="campaignsCreate.countryHint"
values={{ code: selectedCode }}
components={{ 0: <span className="font-mono text-foreground" /> }}
/>
</p>
)}
</div>
);
}
export default CreateCampaignPage;