diff --git a/src/locales/en.json b/src/locales/en.json index 205ac19e..f18d01f2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -916,10 +916,19 @@ "myWalletLabel": "{{name}}'s wallet", "myWalletDefault": "My wallet", "walletChoose": "Choose a wallet", - "walletCustom": "Custom", + "walletCustom": "Custom wallet", + "walletEditAria": "Change destination wallet", + "walletUseCustom": "Use a custom wallet instead", + "walletUseMine": "Use my Agora wallet", "acceptAll": "Accept all payment types", "acceptPublic": "Accept public payments only", "acceptPrivate": "Accept private payments only", + "acceptAllShort": "All", + "acceptPublicShort": "Public", + "acceptPrivateShort": "Private", + "acceptAllHint": "Accept both public on-chain and private silent payments.", + "acceptPublicHint": "Only accept on-chain donations to a public address.", + "acceptPrivateHint": "Only accept silent payments — donor addresses stay private.", "customWalletIntro": "Enter a Bitcoin address, a silent-payment code, or both. At least one is required.", "bitcoinAddress": "Bitcoin address", "bitcoinAddressPlaceholder": "bc1q… or bc1p…", diff --git a/src/pages/CreateCampaignPage.tsx b/src/pages/CreateCampaignPage.tsx index 67747a2a..7f6b4acd 100644 --- a/src/pages/CreateCampaignPage.tsx +++ b/src/pages/CreateCampaignPage.tsx @@ -9,10 +9,14 @@ import { nip19 } from 'nostr-tools'; import { AlertTriangle, ArrowLeft, + Bitcoin, HandHeart, HelpCircle, Loader2, MapPin, + Pencil, + Radar, + Sparkles, Wallet, X, } from 'lucide-react'; @@ -27,23 +31,20 @@ 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 { Skeleton } from '@/components/ui/skeleton'; import { Textarea } from '@/components/ui/textarea'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppContext } from '@/hooks/useAppContext'; import { useAuthor } from '@/hooks/useAuthor'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; 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 { formatBTC, satsToUSD } from '@/lib/bitcoin'; import { CAMPAIGN_KIND, encodeCampaignNaddr, @@ -655,6 +656,8 @@ export function CreateCampaignPage() { silentPaymentSupported={silentPaymentSupported} displayName={userDisplayName} picture={userMetadata?.picture} + totalBalance={hdWallet.totalBalance} + balanceLoading={hdWalletAvailable && hdWallet.isLoading} walletSource={walletSource} onWalletSourceChange={setWalletSource} mineAccept={mineAccept} @@ -1160,27 +1163,31 @@ function CampaignWizard({ /** * Wallet picker for the campaign form. * - * For nsec users we render two dropdowns: + * Two modes selectable via a single inline toggle: * - * 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. + * 1. **My wallet** (`'mine'`, default when nsec is available) — a + * compact identity card shows the user's avatar, display name and + * live USD/BTC balance, modelled on the wallet-page balance + * treatment. A small pencil affordance to the right is the entry + * point to swap into custom mode; mirror-link beneath the inputs + * swaps back. The HD-wallet mode also surfaces a segmented + * "Accept" picker (All / Public / Private) that picks which + * donation types the campaign accepts. + * 2. **Custom** (`'custom'`) — two address inputs (on-chain + silent + * payment). At least one must parse to a valid endpoint of its + * mode. * - * 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. + * Users without nsec access (extension / bunker logins) never see the + * "mine" branch — `hdWalletAvailable` is false and we drop straight + * to the custom inputs. */ function WalletPicker({ hdWalletAvailable, silentPaymentSupported, displayName, picture, + totalBalance, + balanceLoading, walletSource, onWalletSourceChange, mineAccept, @@ -1196,6 +1203,10 @@ function WalletPicker({ silentPaymentSupported: boolean; displayName: string; picture?: string; + /** Live HD-wallet balance in sats (confirmed + pending + SP). */ + totalBalance: number; + /** True while the initial HD scan is still running — drives skeleton. */ + balanceLoading: boolean; walletSource: 'mine' | 'custom'; onWalletSourceChange: (value: 'mine' | 'custom') => void; mineAccept: 'all' | 'public' | 'private'; @@ -1208,85 +1219,122 @@ function WalletPicker({ parsedCustomSp: ReturnType; }) { const { t } = useTranslation(); + const { data: btcPrice } = useBtcPrice(); const initial = displayName.charAt(0).toUpperCase() || '?'; const myWalletLabel = displayName ? t('campaignsCreate.myWalletLabel', { name: displayName }) : t('campaignsCreate.myWalletDefault'); - return ( -
- {hdWalletAvailable ? ( - <> - - - {walletSource === 'mine' && ( - - )} - - ) : ( + // When no HD wallet is available (extension / bunker login) there's + // no "mine" branch to choose — render only the custom inputs with a + // short intro line so the user understands what's expected. + if (!hdWalletAvailable) { + return ( +

{t('campaignsCreate.customWalletIntro')}

- )} + + +
+ ); + } + + return ( +
+ {walletSource === 'mine' ? ( + <> + {/* Identity + balance card. Tapping the pencil swaps into the + custom-wallet inputs without changing wizard step. */} + + + {/* "Use a custom wallet" sub-link — quieter affordance for the + same swap, since the pencil alone is easy to miss. */} + + + {/* Accept-mode segmented picker. Default 'all' (HD + SP); the + non-SP options are only relevant if SP is unsupported. */} + + + ) : ( + <> + {/* Header strip — name the current mode and offer the swap + back to the user's wallet. Mirrors the pencil card so + both directions of the toggle feel symmetric. */} +
+
+ + + + {t('campaignsCreate.walletCustom')} +
+ +
- {walletSource === 'custom' && ( -
-
+ )}
); } +/** + * Segmented "Accept" picker for the HD-wallet branch. Three pill + * buttons (All / Public / Private) with a one-line caption beneath + * that explains the current selection. Public is always available; + * the All and Private buttons disable when SP isn't supported + * (extension / bunker logins). + */ +function AcceptModePicker({ + value, + onChange, + silentPaymentSupported, +}: { + value: 'all' | 'public' | 'private'; + onChange: (next: 'all' | 'public' | 'private') => void; + silentPaymentSupported: boolean; +}) { + const { t } = useTranslation(); + + const caption = { + all: t('campaignsCreate.acceptAllHint'), + public: t('campaignsCreate.acceptPublicHint'), + private: t('campaignsCreate.acceptPrivateHint'), + }[value]; + + return ( +
+ { + if (!next) return; + onChange(next as 'all' | 'public' | 'private'); + }} + variant="outline" + className="grid w-full grid-cols-3 gap-1.5" + > + + + {t('campaignsCreate.acceptAllShort')} + + + + {t('campaignsCreate.acceptPublicShort')} + + + + {t('campaignsCreate.acceptPrivateShort')} + + +

{caption}

+
+ ); +} + /** * 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