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.
48 lines
1.9 KiB
TypeScript
48 lines
1.9 KiB
TypeScript
import { satsToUSDWhole } from '@/lib/bitcoin';
|
||
|
||
/**
|
||
* Formats a sats amount into a short, human-readable string.
|
||
*
|
||
* - `< 10,000` sats — shows the exact number with thousands separators.
|
||
* - `10,000 – 999,999` sats — rounds to the nearest thousand (`12K sats`).
|
||
* - `1,000,000 – 99,999,999` sats — two decimals of millions (`1.23M sats`).
|
||
* - `>= 100,000,000` sats (1 BTC) — switches to BTC with two decimals.
|
||
*/
|
||
export function formatSatsShort(sats: number): string {
|
||
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`;
|
||
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M sats`;
|
||
if (sats >= 10_000) return `${(sats / 1_000).toFixed(0)}K sats`;
|
||
return `${sats.toLocaleString()} sats`;
|
||
}
|
||
|
||
/**
|
||
* Renders a sats count as USD (whole dollars) when a BTC price is
|
||
* available, falling back to {@link formatSatsShort} otherwise. Used by
|
||
* campaign cards and the hero spotlight so totals are consistent across
|
||
* the app.
|
||
*/
|
||
export function formatCampaignAmount(sats: number, btcPrice: number | undefined): string {
|
||
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
|
||
return formatSatsShort(sats);
|
||
}
|
||
|
||
/**
|
||
* Formats an integer USD amount (the campaign goal unit per NIP.md Kind
|
||
* 33863). Uses thousands separators and a leading `$`. Negative or
|
||
* non-finite values render as `$0`.
|
||
*/
|
||
export function formatUsdGoal(usd: number): string {
|
||
if (!Number.isFinite(usd) || usd <= 0) return '$0';
|
||
return `$${Math.floor(usd).toLocaleString()}`;
|
||
}
|
||
|
||
/**
|
||
* Convert sats to USD using a live BTC/USD price. Returns `undefined` if
|
||
* the price isn't available — callers should fall back to the sats
|
||
* representation in that case.
|
||
*/
|
||
export function satsToUsd(sats: number, btcPrice: number | undefined): number | undefined {
|
||
if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return undefined;
|
||
return (sats / 100_000_000) * btcPrice;
|
||
}
|