From 6e4eff602a4b4cae062b5490400a98385d04cbec Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 28 May 2026 16:05:06 -0700 Subject: [PATCH] Redesign the wizard's wallet step around the user's wallet card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet step previously stacked two generic dropdowns (source + accept) on top of two custom-address inputs that the user had to expand explicitly. Every donation flow starts the same way: pick "my wallet" and accept everything. The redesign treats that path as the default view, not one of two dropdown options. What changed: * The Source dropdown becomes an inline identity card — avatar + display name on the left, live USD/BTC balance on the right (modeled on the wallet-page treatment), pencil affordance on the far right. Tapping anywhere on the card swaps the view into the custom-wallet inputs; a quieter "Use a custom wallet instead" sub-link beneath it offers the same swap. From custom mode a small "← Use my Agora wallet" mirror-link snaps back. * The Accept dropdown becomes a three-pill segmented ToggleGroup — All / Public / Private — with icons (sparkles / bitcoin / radar) and a one-line caption beneath that explains the current selection. The All and Private buttons disable when silent payments aren't supported by the current login. Default stays 'all' (HD wallet with SP); empty toggle deselects are coerced back to the previous value since the field is required. * Balance comes from the parent's existing useHdWallet hook (passed in via new `totalBalance` + `balanceLoading` props) plus an in-component useBtcPrice call. Loading state shows a small Skeleton in place of the price line; missing price falls back to BTC-only. * When no HD wallet is available (extension / bunker logins) the picker collapses to just the two custom inputs with the existing intro copy — no card, no toggle. Existing locale keys are reused where the strings still fit; new ones cover the toggle short labels, the captions, and the swap affordances. The wider "Custom" label widens to "Custom wallet" so the segmented header reads cleanly. Other locales fall back to English on the new keys until the copy settles. --- src/locales/en.json | 11 +- src/pages/CreateCampaignPage.tsx | 301 +++++++++++++++++++++---------- 2 files changed, 220 insertions(+), 92 deletions(-) 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