935c121bab
Replaces every kind 30223 surface with kind 33863 -- the self-authored fundraising campaign with a single `w` Bitcoin wallet endpoint. Hard cutoff: no migration, no dual-read, no legacy support. Schema (src/lib/campaign.ts): - `CAMPAIGN_KIND` constant bumped 30223 -> 33863. - New `CampaignWallet` type with `onchain` (`bc1q`/`bc1p`) and `sp` (`sp1`) modes, prefix-disambiguated. Bitcoin-mainnet only; testnet/regtest/lightning prefixes are rejected at parse time. - New `parseCampaignWallet()` validates bech32 via bitcoinjs-lib for on-chain addresses and shape-checks silent-payment codes. - `ParsedCampaign` drops `recipients`, `category`, `tags`, `location`, `archived`, `image` (-> `banner`), `goalSats` (-> `goalUsd`). Adds `wallet` and `bannerImeta` (parsed NIP-92). - `CAMPAIGN_CATEGORIES`, `CampaignCategory`, `LEGACY_CAMPAIGN_CATEGORY_ALIASES`, `getCampaignPrimaryTagLabel`, `splitDonation`, `minDonationForSplit`, `DonationSplit`, `CampaignRecipient` removed. Publishing (useDonateCampaign): - Single-output PSBT paying `campaign.wallet.value`. - Kind 8333 receipt has NO `p` tags -- campaigns are not Nostr-identity recipients. `i`, `amount`, `a`, `K`, `alt` only. - SP campaigns are refused with a clear error directing donors to external BIP-352-capable wallets via the on-page QR/copy panel. Verification (useOnchainZaps.verifyOnchainZap): - Two modes: identity-recipient (existing `p`-tag derivation) and campaign-wallet (match outputs against `campaign.w`). The branch is selected by whether the receipt has an `a` tag pointing at a kind 33863 campaign. SP-targeted receipts are rejected. Querying (useCampaigns, useAllCampaigns, useCampaignDonations): - Drop `category`, `recipientPubkeys`, `includeArchived` options. - `useCampaignDonations` now takes a `ParsedCampaign` and verifies every receipt on-chain against the campaign's `w` address before counting it. SP campaigns short-circuit to zeros. - Search drops `location` and `t`-tag branches; title/summary/story only. UI: - `CampaignCard`, `HeroCampaignSpotlight`, `CampaignsPage`: drop recipient counts, archived/category badges, `location`; use `banner`. Silent-payment campaigns render a "Private -- totals not public" notice instead of progress. - `CampaignDetailPage`: archive flow replaced with NIP-09 kind 5 deletion. Drops the multi-beneficiary recipient column. Donate column shows the in-app PSBT donate button (on-chain) plus the always- available external-wallet QR/copy panel. SP campaigns show the panel only -- no in-app donate. - `CreateCampaignPage`: drops Beneficiaries section, tag input, and USD-to-sats conversion. Adds a Wallet field with mode-aware validation hint. Goal is integer USD. Banner upload captures NIP-94 tags and converts to NIP-92 `imeta` at publish. - `DonateDialog`: collapses ~1200 LOC of split logic into a single- output flow. Form -> Confirm -> Success. Logged-out and signer- unsupported users are pointed at the external-wallet panel. - New `CampaignWalletDonatePanel` component (replaces the pubkey-derived `BeneficiaryDonateDialog`). QR + copy + open-in-wallet for any `bc1`/`sp1` endpoint, with mode-appropriate privacy notice. Removed: - `useArchiveCampaign` hook (closure via NIP-09 deletion only). - `ClaimPage` and its `/claim` route (claim-for-someone-else flow no longer applies -- campaigns are self-authored). - `BeneficiaryDonateDialog.tsx` (replaced by `CampaignWalletDonatePanel.tsx`). - Community-donate synthesis hack in `CommunityDetailPage` (no more fabricating a `ParsedCampaign` from community moderators). NIP.md was updated separately to specify Kind 33863.
161 lines
6.4 KiB
TypeScript
161 lines
6.4 KiB
TypeScript
import { Link } from 'react-router-dom';
|
|
import { ArrowRight, MapPin, ShieldCheck } from 'lucide-react';
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { cn } from '@/lib/utils';
|
|
import { encodeCampaignNaddr, getCampaignCountryLabel, type ParsedCampaign } from '@/lib/campaign';
|
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
|
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
|
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
|
|
|
|
interface HeroCampaignSpotlightProps {
|
|
/** Campaign to feature. `null` renders the empty placeholder. */
|
|
campaign: ParsedCampaign | null;
|
|
/** Show a skeleton while the parent is still loading featured campaigns. */
|
|
isLoading?: boolean;
|
|
/** Extra classes for the outer wrapper. */
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Banner-overlay spotlight for the active campaign — title, summary,
|
|
* author, location, and a "View campaign" CTA — rendered directly on the
|
|
* hero photo (no card chrome). The hero photo IS the background, so this
|
|
* component is purely a text overlay.
|
|
*
|
|
* Parent (`CampaignsPage`) drives the `campaign` prop, cycling on a timer
|
|
* or pinning to whichever marker the user clicked on the globe.
|
|
*/
|
|
export function HeroCampaignSpotlight({
|
|
campaign,
|
|
isLoading = false,
|
|
className,
|
|
}: HeroCampaignSpotlightProps) {
|
|
// useAuthor must be called unconditionally to keep hook order stable —
|
|
// when there's no campaign yet we pass an empty pubkey and ignore the
|
|
// (no-op) result below. Same for donations + BTC price.
|
|
const author = useAuthor(campaign?.pubkey ?? '');
|
|
const { data: stats } = useCampaignDonations(campaign ?? undefined);
|
|
const { data: btcPrice } = useBtcPrice();
|
|
|
|
if (isLoading && !campaign) {
|
|
return (
|
|
<div className={cn('space-y-1.5', className)}>
|
|
<Skeleton className="h-5 w-52 bg-white/20" />
|
|
<Skeleton className="h-3 w-64 bg-white/20" />
|
|
<Skeleton className="h-3 w-40 bg-white/20" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!campaign) return null;
|
|
|
|
const naddr = encodeCampaignNaddr(campaign);
|
|
const meta = author.data?.metadata;
|
|
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
|
|
const authorPicture = sanitizeUrl(meta?.picture);
|
|
const countryLabel = getCampaignCountryLabel(campaign);
|
|
const isSilentPayment = campaign.wallet.mode === 'sp';
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
// Compact text block over the photo — always white regardless of
|
|
// theme since the hero is always a dark-scrimed photo.
|
|
'space-y-1.5 text-white hero-text-shadow-soft',
|
|
className,
|
|
)}
|
|
>
|
|
<p className="text-base font-semibold leading-snug line-clamp-1">
|
|
{campaign.title}
|
|
</p>
|
|
|
|
{campaign.summary && (
|
|
<p className="text-xs text-white/80 line-clamp-2 max-w-xs">
|
|
{campaign.summary}
|
|
</p>
|
|
)}
|
|
|
|
{/* Progress / goal. Hand-rolled instead of using <CampaignProgress>
|
|
so we can tune the bar for legibility on top of a photo: dark
|
|
translucent track, glowing primary fill. When the campaign has no
|
|
goal tag, the bar is omitted entirely and we only show the raised
|
|
total. Silent-payment campaigns hide totals by design (per
|
|
NIP.md Kind 33863). */}
|
|
{isSilentPayment ? (
|
|
<div className="space-y-1.5 pt-1 max-w-xs">
|
|
<div className="inline-flex items-center gap-1.5 text-[11px] text-white/85 [text-shadow:none]">
|
|
<ShieldCheck className="size-3" />
|
|
<span>Private campaign — totals not public</span>
|
|
</div>
|
|
{campaign.goalUsd && campaign.goalUsd > 0 && (
|
|
<div className="text-[11px] text-white/70 [text-shadow:none]">
|
|
Target: {formatUsdGoal(campaign.goalUsd)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (() => {
|
|
const raised = stats?.totalSats ?? 0;
|
|
const goal = campaign.goalUsd;
|
|
const hasGoal = !!goal && goal > 0;
|
|
const raisedUsd = satsToUsd(raised, btcPrice);
|
|
const pct = hasGoal && raisedUsd !== undefined
|
|
? Math.min(100, Math.round((raisedUsd / goal!) * 100))
|
|
: 0;
|
|
return (
|
|
<div className="space-y-1.5 pt-1 max-w-xs">
|
|
{hasGoal && (
|
|
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-black/40 ring-1 ring-white/15">
|
|
<div
|
|
className="absolute inset-y-0 left-0 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary)/0.7)] motion-safe:transition-[width] motion-safe:duration-500"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex items-baseline justify-between gap-2 text-[11px] [text-shadow:none]">
|
|
<span className="font-semibold text-white">
|
|
{formatCampaignAmount(raised, btcPrice)}
|
|
{!hasGoal && <span className="ml-1 font-normal text-white/70">raised</span>}
|
|
</span>
|
|
{hasGoal && (
|
|
<span className="text-white/70">
|
|
of {formatUsdGoal(goal!)} goal
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-white/75 pt-0.5">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Avatar className="size-4 ring-1 ring-white/40">
|
|
{authorPicture && <AvatarImage src={authorPicture} alt="" />}
|
|
<AvatarFallback className="text-[8px]">
|
|
{authorName.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="font-medium">{authorName}</span>
|
|
</span>
|
|
{countryLabel && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<MapPin className="size-3" />
|
|
<span className="truncate max-w-[16ch]">{countryLabel}</span>
|
|
</span>
|
|
)}
|
|
<Link
|
|
to={`/${naddr}`}
|
|
className="inline-flex items-center gap-1 font-medium text-primary hover:text-primary/80 focus-visible:outline-none focus-visible:underline"
|
|
>
|
|
View
|
|
<ArrowRight className="size-3" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|